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 +4 -4
- data/CHANGELOG.md +13 -0
- data/README.md +5 -3
- data/lib/mana/config.rb +1 -7
- data/lib/mana/engine.rb +0 -5
- data/lib/mana/knowledge.rb +0 -4
- data/lib/mana/memory.rb +7 -164
- data/lib/mana/memory_store.rb +4 -4
- data/lib/mana/prompt_builder.rb +1 -1
- data/lib/mana/tool_handler.rb +3 -5
- data/lib/mana/version.rb +1 -1
- data/lib/mana.rb +0 -5
- metadata +4 -21
- data/exe/mana +0 -12
- data/lib/mana/chat.rb +0 -301
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8d39f448801c8d3f8c7a9c14d30688bfc0469f6aac6d2d646faaadec7e3b93a6
|
|
4
|
+
data.tar.gz: '0278e9d363a63eb4f39c4b43149ac583f068c358bc09fc316ba64e5a376849f3'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
196
|
-
- **Long-term memory** — persistent facts stored on disk (
|
|
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
|
|
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
|
|
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.
|
data/lib/mana/knowledge.rb
CHANGED
|
@@ -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
|
data/lib/mana/memory_store.rb
CHANGED
|
@@ -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 >
|
|
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 >
|
|
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.
|
|
75
|
+
# Default fallback — project-local .mana directory
|
|
76
|
+
File.join(Dir.pwd, ".mana")
|
|
77
77
|
end
|
|
78
78
|
end
|
|
79
79
|
end
|
data/lib/mana/prompt_builder.rb
CHANGED
|
@@ -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
|
|
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) }
|
data/lib/mana/tool_handler.rb
CHANGED
|
@@ -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
|
|
89
|
-
#
|
|
90
|
-
|
|
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
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.
|
|
4
|
+
version: 0.5.11
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Carl Li
|
|
8
8
|
autorequire:
|
|
9
|
-
bindir:
|
|
9
|
+
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-03-
|
|
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
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
|