ruby-mana 0.5.10 → 0.5.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dec47003f35644ccba81fd005d200be2046c9e32c8a939ce1b4e74d90defc9cc
4
- data.tar.gz: 2f7986b4125ca844517002630195c16fedd0ea182a753ae3836d3cd951721d20
3
+ metadata.gz: 8d39f448801c8d3f8c7a9c14d30688bfc0469f6aac6d2d646faaadec7e3b93a6
4
+ data.tar.gz: '0278e9d363a63eb4f39c4b43149ac583f068c358bc09fc316ba64e5a376849f3'
5
5
  SHA512:
6
- metadata.gz: caa5b68af0f5658f1cc5ff0c053b4b15cb44e88872d07102454171abd4b5d53eacfe39db1d5b8622e76f87a8f45e44944a5e0dfd44055975dfc2324d3af24560
7
- data.tar.gz: 202a6b979ecd66f3c8dd4c949d299479ce73ff47e4a6cbc98d06651d103bc1fd5a791bdd0719be9abdfc1bf2aa745fdc5ba73a557119f9c1452d8b37689212ae
6
+ metadata.gz: 4940fea532999f3381a859cf970eaa4cd4075e8ed281bbb1a80d8b6ec4d91fa9a7c2990cb59e45b59ad6ee0e0ad0e7d72186e993b83035da857aab9c373fc12b
7
+ data.tar.gz: 84cd628d4af242be45ac7730d20e587e5dc69951e3598c0b66525c8b1e3ba2b0e4e3463b62533d58bff72361d0e2a492acd6743dce10ff9afc7c578b882b6e38
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.11] - 2026-03-27
4
+
5
+ ### Changed
6
+ - Memory storage default path changed from `~/.mana/memory` to `{cwd}/.mana` (project-local)
7
+ - Long-term memory injection uses keyword search when collection exceeds 20 memories
8
+
9
+ ### Added
10
+ - Session persistence — short-term memory and summaries survive across restarts (`persist_session` config, default: true)
11
+ - `Memory#save_session` / auto-load on init for session continuity
12
+ - `Memory#search(query, top_k:)` — keyword-based fuzzy matching over long-term memories
13
+ - `config.persist_session` (default: `true`) and `config.memory_top_k` (default: `10`) options
14
+ - `MemoryStore#read_session` / `#write_session` abstract interface and `FileStore` implementation
15
+
3
16
  ## [0.5.10] - 2026-03-27
4
17
 
5
18
  ### Added
data/README.md CHANGED
@@ -192,15 +192,15 @@ Each nested call gets its own conversation context. The outer LLM only sees the
192
192
 
193
193
  Mana has two types of memory:
194
194
 
195
- - **Short-term memory** — conversation history within the current process. Each `~"..."` call appends to it, so consecutive calls share context. Cleared when the process exits.
196
- - **Long-term memory** — persistent facts stored on disk (`~/.mana/`). Survives across script executions. The LLM can save facts via the `remember` tool.
195
+ - **Short-term memory** — conversation history within the current process. Each `~"..."` call appends to it, so consecutive calls share context. Persisted to session files by default (survives restarts).
196
+ - **Long-term memory** — persistent facts stored on disk (`.mana/` in your project directory). Survives across script executions. The LLM can save facts via the `remember` tool.
197
197
 
198
198
  ```ruby
199
199
  ~"translate <text1> to Japanese, store in <result1>"
200
200
  ~"translate <text2> to the same language, store in <result2>" # remembers "Japanese"
201
201
 
202
202
  ~"remember that the user prefers concise output"
203
- # persists to ~/.mana/ — available in future script runs
203
+ # persists to .mana/ — available in future script runs
204
204
  ```
205
205
 
206
206
  ```ruby
@@ -275,6 +275,8 @@ Mana.configure do |c|
275
275
  c.memory_keep_recent = 4 # keep last 4 rounds during compaction
276
276
  c.compact_model = nil # nil = use main model for compaction
277
277
  c.memory_store = Mana::FileStore.new # default file-based persistence
278
+ c.persist_session = true # persist short-term memory across restarts
279
+ c.memory_top_k = 10 # max memories to inject when searching (> 20 memories)
278
280
  end
279
281
  ```
280
282
 
data/lib/mana/config.rb CHANGED
@@ -9,13 +9,11 @@ module Mana
9
9
  # base_url - Custom API endpoint, falls back to ANTHROPIC_API_URL or OPENAI_API_URL
10
10
  # backend - :anthropic, :openai, or nil (auto-detect from model name)
11
11
  # timeout - HTTP timeout in seconds (default: 120)
12
- # memory_pressure - Token ratio (0-1) that triggers memory compaction (default: 0.7)
13
12
  class Config
14
13
  attr_accessor :model, :temperature, :api_key, :max_iterations, :base_url,
15
14
  :backend, :verbose,
16
15
  :namespace, :memory_store, :memory_path,
17
- :context_window, :memory_pressure, :memory_keep_recent,
18
- :compact_model, :on_compact
16
+ :context_window
19
17
  attr_reader :timeout
20
18
 
21
19
  DEFAULT_ANTHROPIC_URL = "https://api.anthropic.com"
@@ -38,10 +36,6 @@ module Mana
38
36
  @memory_store = nil
39
37
  @memory_path = nil
40
38
  @context_window = 128_000
41
- @memory_pressure = 0.7
42
- @memory_keep_recent = 4
43
- @compact_model = nil
44
- @on_compact = nil
45
39
  end
46
40
 
47
41
  # Set timeout; must be a positive number
data/lib/mana/engine.rb CHANGED
@@ -186,8 +186,6 @@ module Mana
186
186
  system_prompt = build_system_prompt(context)
187
187
 
188
188
  memory = @incognito ? nil : Memory.current
189
- # Wait for any in-progress background compaction before reading messages
190
- memory&.wait_for_compaction
191
189
 
192
190
  messages = memory ? memory.short_term : []
193
191
 
@@ -272,9 +270,6 @@ module Mana
272
270
  messages << { role: "assistant", content: [{ type: "text", text: "Done: #{done_result}" }] }
273
271
  end
274
272
 
275
- # Schedule compaction if needed (runs in background, skip for nested)
276
- memory&.schedule_compaction unless nested
277
-
278
273
  # Return written variables so Ruby 4.0+ users can capture them:
279
274
  # result = ~"compute average and store in <result>"
280
275
  # Single write -> return the value directly; multiple -> return Hash.
@@ -136,8 +136,6 @@ module Mana
136
136
  Namespace is auto-detected from the git repo name, Gemfile directory, or cwd.
137
137
  Configurable via: Mana.configure { |c| c.memory_path = "/custom/path" }
138
138
  Or provide a custom MemoryStore subclass for Redis, DB, etc.
139
- - Background compaction: when short-term memory exceeds the token pressure threshold
140
- (currently #{Mana.config.memory_pressure}), old messages are summarized in a background thread.
141
139
  - Incognito mode: Mana.incognito { ~"..." } disables all memory.
142
140
  The LLM can store facts via the `remember` tool. These persist across script executions.
143
141
  TEXT
@@ -166,8 +164,6 @@ module Mana
166
164
  - timeout: #{c.timeout}s
167
165
  - max_iterations: #{c.max_iterations}
168
166
  - context_window: #{c.context_window}
169
- - memory_pressure: #{c.memory_pressure}
170
- - memory_keep_recent: #{c.memory_keep_recent}
171
167
  - verbose: #{c.verbose}
172
168
  All options can be set via Mana.configure { |c| ... } or environment variables
173
169
  (MANA_MODEL, MANA_BACKEND, MANA_TIMEOUT, MANA_VERBOSE, ANTHROPIC_API_KEY, OPENAI_API_KEY).
data/lib/mana/memory.rb CHANGED
@@ -10,8 +10,6 @@ module Mana
10
10
  @long_term = []
11
11
  @summaries = []
12
12
  @next_id = 1
13
- @compact_mutex = Mutex.new
14
- @compact_thread = nil
15
13
  load_long_term
16
14
  end
17
15
 
@@ -48,7 +46,6 @@ module Mana
48
46
  # --- Token estimation ---
49
47
 
50
48
  # Estimate total token count across short-term messages, long-term facts, and summaries.
51
- # Used to determine when memory compaction is needed.
52
49
  def token_count
53
50
  count = 0
54
51
  @short_term.each do |msg|
@@ -69,6 +66,13 @@ module Mana
69
66
  count
70
67
  end
71
68
 
69
+ # Rough token estimate: ~4 characters per token
70
+ def estimate_tokens(text)
71
+ return 0 unless text.is_a?(String)
72
+
73
+ (text.length / 4.0).ceil
74
+ end
75
+
72
76
  # --- Memory management ---
73
77
 
74
78
  # Clear both short-term and long-term memory
@@ -109,44 +113,6 @@ module Mana
109
113
  entry
110
114
  end
111
115
 
112
- # --- Compaction ---
113
-
114
- # Synchronous compaction: wait for any background run, then compact immediately
115
- def compact!
116
- wait_for_compaction
117
- perform_compaction
118
- end
119
-
120
- # Check if token usage exceeds the configured memory pressure threshold
121
- def needs_compaction?
122
- cw = context_window
123
- token_count > (cw * Mana.config.memory_pressure)
124
- end
125
-
126
- # Launch background compaction if token pressure exceeds the threshold.
127
- # Only one compaction thread runs at a time (guarded by mutex).
128
- def schedule_compaction
129
- return unless needs_compaction?
130
-
131
- @compact_mutex.synchronize do
132
- # Skip if a compaction is already in progress
133
- return if @compact_thread&.alive?
134
-
135
- @compact_thread = Thread.new do
136
- perform_compaction
137
- rescue => e
138
- # Silently handle compaction errors — don't crash the main thread
139
- $stderr.puts "Mana compaction error: #{e.message}" if $DEBUG
140
- end
141
- end
142
- end
143
-
144
- # Block until the background compaction thread finishes (if running)
145
- def wait_for_compaction
146
- thread = @compact_mutex.synchronize { @compact_thread }
147
- thread&.join
148
- end
149
-
150
116
  # --- Display ---
151
117
 
152
118
  # Human-readable summary: counts and token usage
@@ -161,13 +127,6 @@ module Mana
161
127
  @short_term.count { |m| m[:role] == "user" && m[:content].is_a?(String) }
162
128
  end
163
129
 
164
- # Rough token estimate: ~4 characters per token
165
- def estimate_tokens(text)
166
- return 0 unless text.is_a?(String)
167
-
168
- (text.length / 4.0).ceil
169
- end
170
-
171
130
  def context_window
172
131
  Mana.config.context_window
173
132
  end
@@ -209,121 +168,5 @@ module Mana
209
168
  # Set next ID to one past the highest existing ID
210
169
  @next_id = (@long_term.map { |m| m[:id] }.max || 0) + 1
211
170
  end
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.
216
- def perform_compaction
217
- keep_recent = Mana.config.memory_keep_recent
218
- # Find indices of user-prompt messages (each marks a conversation round)
219
- user_indices = @short_term.each_with_index
220
- .select { |msg, _| msg[:role] == "user" && msg[:content].is_a?(String) }
221
- .map(&:last)
222
-
223
- # Not enough rounds to compact — nothing to do
224
- return if user_indices.size <= keep_recent
225
-
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]
230
- old_messages = @short_term[0...cutoff_user_idx]
231
- return if old_messages.empty?
232
-
233
- # Build text from old messages for summarization
234
- text_parts = old_messages.map do |msg|
235
- content = msg[:content]
236
- # Format each message as "role: content" for the summarizer
237
- case content
238
- when String then "#{msg[:role]}: #{content}"
239
- # Array blocks: extract text parts and join
240
- when Array
241
- texts = content.map { |b| b[:text] || b[:content] }.compact
242
- "#{msg[:role]}: #{texts.join(' ')}" unless texts.empty?
243
- end
244
- end.compact
245
-
246
- return if text_parts.empty?
247
-
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
254
-
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)
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
276
- Mana.config.on_compact&.call(summary)
277
- end
278
-
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)
288
- config = Mana.config
289
- model = config.compact_model || config.model
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
326
- "Summary unavailable"
327
- end
328
171
  end
329
172
  end
@@ -25,7 +25,7 @@ module Mana
25
25
 
26
26
 
27
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
28
+ # Storage path resolution: explicit base_path > config.memory_path > {cwd}/.mana
29
29
  class FileStore < MemoryStore
30
30
  # Optional base_path overrides default storage location
31
31
  def initialize(base_path = nil)
@@ -65,15 +65,15 @@ module Mana
65
65
  end
66
66
 
67
67
  # Resolve the base directory for memory storage.
68
- # Priority: explicit base_path > config.memory_path > ~/.mana/memory
68
+ # Priority: explicit base_path > config.memory_path > {cwd}/.mana
69
69
  def base_dir
70
70
  return File.join(@base_path, "memory") if @base_path
71
71
 
72
72
  custom_path = Mana.config.memory_path
73
73
  return File.join(custom_path, "memory") if custom_path
74
74
 
75
- # Default fallback
76
- File.join(Dir.home, ".mana", "memory")
75
+ # Default fallback — project-local .mana directory
76
+ File.join(Dir.pwd, ".mana")
77
77
  end
78
78
  end
79
79
  end
@@ -135,7 +135,7 @@ module Mana
135
135
 
136
136
  # User-defined classes/modules (skip Ruby internals)
137
137
  skip = [Object, Kernel, BasicObject, Module, Class, Mana, Mana::Engine,
138
- Mana::Memory, Mana::Config, Mana::Chat]
138
+ Mana::Memory, Mana::Config]
139
139
  user_classes = ObjectSpace.each_object(Class)
140
140
  .reject { |c| c.name.nil? || c.name.start_with?("Mana::") || c.name.start_with?("#<") }
141
141
  .reject { |c| skip.include?(c) }
@@ -85,11 +85,9 @@ module Mana
85
85
  rescue LLMError
86
86
  # LLMError must propagate to the caller (e.g. from the error tool)
87
87
  raise
88
- rescue SyntaxError => e
89
- # Catch syntax errors from body eval so the LLM can retry with corrected code
90
- "error: #{e.class}: #{e.message}"
91
- rescue => e
92
- # Return errors as strings so the LLM can see and react to them
88
+ rescue ScriptError, StandardError => e
89
+ # ScriptError covers SyntaxError, LoadError, NotImplementedError
90
+ # StandardError covers everything else (NameError, TypeError, etc.)
93
91
  "error: #{e.class}: #{e.message}"
94
92
  end
95
93
 
data/lib/mana/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mana
4
- VERSION = "0.5.10"
4
+ VERSION = "0.5.11"
5
5
  end
data/lib/mana.rb CHANGED
@@ -17,7 +17,6 @@ require_relative "mana/introspect"
17
17
  require_relative "mana/compiler"
18
18
  require_relative "mana/string_ext"
19
19
  require_relative "mana/mixin"
20
- require_relative "mana/chat"
21
20
 
22
21
  module Mana
23
22
  class Error < StandardError; end
@@ -70,10 +69,6 @@ module Mana
70
69
  Compiler.cache_dir = dir
71
70
  end
72
71
 
73
- # Enter interactive chat mode. Mana will have access to the caller's binding.
74
- def chat
75
- Chat.start(binding.of_caller(1))
76
- end
77
72
  end
78
73
  end
79
74
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-mana
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.10
4
+ version: 0.5.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carl Li
8
8
  autorequire:
9
- bindir: exe
9
+ bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-27 00:00:00.000000000 Z
11
+ date: 2026-03-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: binding_of_caller
@@ -24,20 +24,6 @@ dependencies:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
26
  version: '1.0'
27
- - !ruby/object:Gem::Dependency
28
- name: reline
29
- requirement: !ruby/object:Gem::Requirement
30
- requirements:
31
- - - ">="
32
- - !ruby/object:Gem::Version
33
- version: '0.5'
34
- type: :runtime
35
- prerelease: false
36
- version_requirements: !ruby/object:Gem::Requirement
37
- requirements:
38
- - - ">="
39
- - !ruby/object:Gem::Version
40
- version: '0.5'
41
27
  - !ruby/object:Gem::Dependency
42
28
  name: dotenv
43
29
  requirement: !ruby/object:Gem::Requirement
@@ -57,21 +43,18 @@ description: |
57
43
  with full access to your program's live state. Read/write variables, call
58
44
  functions, manipulate objects — all from a simple ~"..." syntax.
59
45
  email:
60
- executables:
61
- - mana
46
+ executables: []
62
47
  extensions: []
63
48
  extra_rdoc_files: []
64
49
  files:
65
50
  - CHANGELOG.md
66
51
  - LICENSE
67
52
  - README.md
68
- - exe/mana
69
53
  - lib/mana.rb
70
54
  - lib/mana/backends/anthropic.rb
71
55
  - lib/mana/backends/base.rb
72
56
  - lib/mana/backends/openai.rb
73
57
  - lib/mana/binding_helpers.rb
74
- - lib/mana/chat.rb
75
58
  - lib/mana/compiler.rb
76
59
  - lib/mana/config.rb
77
60
  - lib/mana/engine.rb
data/exe/mana DELETED
@@ -1,12 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- begin
5
- require "dotenv/load"
6
- rescue LoadError
7
- # dotenv not installed — skip
8
- end
9
-
10
- require "mana"
11
-
12
- Mana::Chat.start(TOPLEVEL_BINDING)
data/lib/mana/chat.rb DELETED
@@ -1,301 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Mana
4
- # Interactive chat mode — enter with Mana.chat to talk to Mana in your Ruby runtime.
5
- # Supports streaming output, colored prompts, and full access to the caller's binding.
6
- # Auto-detects Ruby code vs natural language. Use '!' prefix to force Ruby execution.
7
- module Chat
8
- USER_PROMPT = "\e[36mmana>\e[0m " # cyan
9
- MANA_PREFIX = "\e[33mmana>\e[0m " # yellow
10
- RUBY_PREFIX = "\e[35m=>\e[0m " # magenta
11
- THINK_COLOR = "\e[3;36m" # italic cyan
12
- TOOL_COLOR = "\e[2;33m" # dim yellow
13
- RESULT_COLOR = "\e[2;32m" # dim green
14
- CODE_COLOR = "\e[36m" # cyan for code
15
- BOLD = "\e[1m" # bold
16
- ERROR_COLOR = "\e[31m" # red
17
- DIM = "\e[2m" # dim
18
- RESET = "\e[0m"
19
-
20
- CONT_PROMPT = "\e[2m \e[0m"
21
- EXIT_COMMANDS = /\A(exit|quit|bye|q)\z/i
22
-
23
- # Entry point — starts the REPL loop with Reline for readline support.
24
- # Reline is required lazily so chat mode doesn't penalize non-interactive usage.
25
- HISTORY_FILE = File.join(Dir.home, ".mana_history")
26
- HISTORY_MAX = 1000
27
-
28
- def self.start(caller_binding)
29
- require "reline"
30
- load_history
31
- puts "#{DIM}Mana chat · type 'exit' to quit#{RESET}"
32
- puts
33
-
34
- loop do
35
- input = read_input
36
- break if input.nil?
37
- next if input.strip.empty?
38
- break if input.strip.match?(EXIT_COMMANDS)
39
-
40
- # Three-tier dispatch:
41
- # "!" prefix — force Ruby eval (bypass ambiguity detection)
42
- # Valid Ruby syntax — try Ruby first, fall back to LLM if NameError
43
- # Everything else — send directly to the LLM
44
- if input.start_with?("!")
45
- eval_ruby(caller_binding, input[1..].strip)
46
- elsif ruby_syntax?(input)
47
- eval_ruby(caller_binding, input) { run_mana(caller_binding, input) }
48
- else
49
- run_mana(caller_binding, input)
50
- end
51
- puts
52
- end
53
-
54
- save_history
55
- puts "#{DIM}bye!#{RESET}"
56
- end
57
-
58
- def self.load_history
59
- return unless File.exist?(HISTORY_FILE)
60
-
61
- File.readlines(HISTORY_FILE, chomp: true).last(HISTORY_MAX).each do |line|
62
- Reline::HISTORY << line
63
- end
64
- rescue StandardError
65
- # ignore corrupt history
66
- end
67
- private_class_method :load_history
68
-
69
- def self.save_history
70
- lines = Reline::HISTORY.to_a.last(HISTORY_MAX)
71
- File.write(HISTORY_FILE, lines.join("\n") + "\n")
72
- rescue StandardError
73
- # ignore write failures
74
- end
75
- private_class_method :save_history
76
-
77
- # Reads input with multi-line support — keeps prompting with continuation
78
- # markers while the buffer contains incomplete Ruby (unclosed blocks, strings, etc.)
79
- def self.read_input
80
- # Second arg to readline: true = add to history, false = don't
81
- buffer = Reline.readline(USER_PROMPT, true)
82
- return nil if buffer.nil?
83
-
84
- while incomplete_ruby?(buffer)
85
- line = Reline.readline(CONT_PROMPT, false)
86
- break if line.nil?
87
- buffer += "\n" + line
88
- end
89
- buffer
90
- end
91
- private_class_method :read_input
92
-
93
- # Heuristic: if RubyVM can compile it, treat as Ruby code.
94
- # This lets users type `x = 1 + 2` without needing the "!" prefix.
95
- def self.ruby_syntax?(input)
96
- RubyVM::InstructionSequence.compile(input)
97
- true
98
- rescue SyntaxError
99
- false
100
- end
101
- private_class_method :ruby_syntax?
102
-
103
- # Distinguishes incomplete code (needs more input) from invalid code (syntax error).
104
- # Only "unexpected end-of-input" and "unterminated" indicate the user is still typing;
105
- # other SyntaxErrors mean the code is complete but malformed.
106
- def self.incomplete_ruby?(code)
107
- RubyVM::InstructionSequence.compile(code)
108
- false
109
- rescue SyntaxError => e
110
- e.message.include?("unexpected end-of-input") ||
111
- e.message.include?("unterminated")
112
- end
113
- private_class_method :incomplete_ruby?
114
-
115
- # Eval Ruby in the caller's binding. On NameError/NoMethodError, yields to the
116
- # fallback block (which sends to the LLM) — this is how ambiguous input like
117
- # "sort this list" gets routed: Ruby rejects it, then the LLM handles it.
118
- def self.eval_ruby(caller_binding, code)
119
- result = caller_binding.eval(code)
120
- puts "#{RUBY_PREFIX}#{result.inspect}"
121
- rescue NameError, NoMethodError => e
122
- block_given? ? yield : puts("#{ERROR_COLOR}#{e.class}: #{e.message}#{RESET}")
123
- rescue => e
124
- puts "#{ERROR_COLOR}#{e.class}: #{e.message}#{RESET}"
125
- end
126
- private_class_method :eval_ruby
127
-
128
- # --- Mana LLM execution with streaming + markdown rendering ---
129
-
130
- # Executes a prompt via the LLM engine with streaming output.
131
- # Uses a line buffer to render complete lines with markdown formatting
132
- # while the LLM streams tokens incrementally.
133
- def self.run_mana(caller_binding, input)
134
- streaming_text = false
135
- in_code_block = false
136
- line_buffer = +"" # mutable string — accumulates partial lines until \n
137
- engine = Engine.new(caller_binding)
138
-
139
- begin
140
- result = engine.execute(input) do |type, *args|
141
- case type
142
- when :text
143
- unless streaming_text
144
- print MANA_PREFIX
145
- streaming_text = true
146
- end
147
-
148
- # Buffer text and flush complete lines with markdown rendering
149
- line_buffer << args[0].to_s
150
- while (idx = line_buffer.index("\n"))
151
- line = line_buffer.slice!(0, idx + 1)
152
- in_code_block = render_line(line.chomp, in_code_block)
153
- puts
154
- end
155
-
156
- when :tool_start
157
- flush_line_buffer(line_buffer, in_code_block) if streaming_text
158
- streaming_text = false
159
- in_code_block = false
160
- line_buffer.clear
161
- name, input_data = args
162
- detail = format_tool_call(name, input_data)
163
- puts "#{TOOL_COLOR} ⚡ #{detail}#{RESET}"
164
-
165
- when :tool_end
166
- name, result_str = args
167
- summary = truncate(result_str.to_s, 120)
168
- puts "#{RESULT_COLOR} ↩ #{summary}#{RESET}" unless summary.start_with?("ok:")
169
- end
170
- end
171
-
172
- # Flush any remaining buffered text
173
- flush_line_buffer(line_buffer, in_code_block) if streaming_text
174
-
175
- # Non-streaming fallback: if no text was streamed (e.g. tool-only response),
176
- # render the final result as a single block
177
- unless streaming_text
178
- display = case result
179
- when Hash then result.inspect
180
- when nil then nil
181
- when String then render_markdown(result)
182
- else result.inspect
183
- end
184
- puts "#{MANA_PREFIX}#{display}" if display
185
- end
186
- rescue LLMError, MaxIterationsError => e
187
- flush_line_buffer(line_buffer, in_code_block) if streaming_text
188
- puts "#{ERROR_COLOR}error: #{e.message}#{RESET}"
189
- end
190
- end
191
- private_class_method :run_mana
192
-
193
- # --- Markdown → ANSI rendering ---
194
-
195
- # Render a single line, handling code block state.
196
- # Returns the new in_code_block state.
197
- def self.render_line(line, in_code_block)
198
- if line.strip.start_with?("```")
199
- if in_code_block
200
- # End of code block — don't print the closing ```
201
- return false
202
- else
203
- # Start of code block — don't print the opening ```
204
- return true
205
- end
206
- end
207
-
208
- if in_code_block
209
- print " #{CODE_COLOR}#{line}#{RESET}"
210
- else
211
- print render_markdown_inline(line)
212
- end
213
- in_code_block
214
- end
215
- private_class_method :render_line
216
-
217
- # Flush remaining text in the line buffer
218
- def self.flush_line_buffer(buffer, in_code_block)
219
- return if buffer.empty?
220
- text = buffer.dup
221
- buffer.clear
222
- if in_code_block
223
- print " #{CODE_COLOR}#{text}#{RESET}"
224
- else
225
- print render_markdown_inline(text)
226
- end
227
- puts
228
- end
229
- private_class_method :flush_line_buffer
230
-
231
- # Convert inline markdown to ANSI codes.
232
- # Handles **bold**, `inline code` (with negative lookbehind to skip ```),
233
- # and # headings. Intentionally minimal — just enough for readable terminal output.
234
- def self.render_markdown_inline(text)
235
- text
236
- .gsub(/\*\*(.+?)\*\*/, "#{BOLD}\\1#{RESET}")
237
- .gsub(/(?<!`)`([^`]+)`(?!`)/, "#{CODE_COLOR}\\1#{RESET}")
238
- .gsub(/^\#{1,3}\s+(.+)/) { BOLD + $1 + RESET }
239
- end
240
- private_class_method :render_markdown_inline
241
-
242
- # Render a complete block of markdown text (for non-streaming results)
243
- def self.render_markdown(text)
244
- lines = text.lines
245
- result = +""
246
- in_code = false
247
- lines.each do |line|
248
- stripped = line.strip
249
- if stripped.start_with?("```")
250
- in_code = !in_code
251
- next
252
- end
253
- if in_code
254
- result << " #{CODE_COLOR}#{line.rstrip}#{RESET}\n"
255
- else
256
- result << render_markdown_inline(line.rstrip) << "\n"
257
- end
258
- end
259
- result.chomp
260
- end
261
- private_class_method :render_markdown
262
-
263
- # --- Tool formatting helpers ---
264
-
265
- def self.format_tool_call(name, input)
266
- case name
267
- when "call_func"
268
- func = input[:name] || input["name"]
269
- args = input[:args] || input["args"] || []
270
- body = input[:body] || input["body"]
271
- desc = func.to_s
272
- desc += "(#{args.map(&:inspect).join(', ')})" if args.any?
273
- desc += " { #{truncate(body, 40)} }" if body
274
- desc
275
- # Display read_var as just the name, write_var as an assignment
276
- when "read_var", "write_var"
277
- var = input[:name] || input["name"]
278
- val = input[:value] || input["value"]
279
- val ? "#{var} = #{truncate(val.inspect, 60)}" : var.to_s
280
- when "read_attr", "write_attr"
281
- obj = input[:obj] || input["obj"]
282
- attr = input[:attr] || input["attr"]
283
- "#{obj}.#{attr}"
284
- when "remember"
285
- content = input[:content] || input["content"]
286
- "remember: #{truncate(content.to_s, 60)}"
287
- when "knowledge"
288
- topic = input[:topic] || input["topic"]
289
- "knowledge(#{topic})"
290
- else
291
- name.to_s
292
- end
293
- end
294
- private_class_method :format_tool_call
295
-
296
- def self.truncate(str, max)
297
- str.length > max ? "#{str[0, max]}..." : str
298
- end
299
- private_class_method :truncate
300
- end
301
- end