ruby-mana 0.5.11 → 0.5.12

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: 8d39f448801c8d3f8c7a9c14d30688bfc0469f6aac6d2d646faaadec7e3b93a6
4
- data.tar.gz: '0278e9d363a63eb4f39c4b43149ac583f068c358bc09fc316ba64e5a376849f3'
3
+ metadata.gz: 0f9221479991a9a07659b6d8aee20d2e0e377f6159e3116fbf01839219d8d011
4
+ data.tar.gz: dc2caf733e6a7f4e330599edff45c72557b81205c7b29382237dfa9b286c903b
5
5
  SHA512:
6
- metadata.gz: 4940fea532999f3381a859cf970eaa4cd4075e8ed281bbb1a80d8b6ec4d91fa9a7c2990cb59e45b59ad6ee0e0ad0e7d72186e993b83035da857aab9c373fc12b
7
- data.tar.gz: 84cd628d4af242be45ac7730d20e587e5dc69951e3598c0b66525c8b1e3ba2b0e4e3463b62533d58bff72361d0e2a492acd6743dce10ff9afc7c578b882b6e38
6
+ metadata.gz: 3389dabca2ba36ab0e3cf2e5ae2f780f9507eadce0f7551c3ebcf22792003342364cf45dc01bd0315713b8998a2c4be0ec5aede5d13eca2adf493339be6bd038
7
+ data.tar.gz: 876a82b19006c6b12822ffaf264d89ac5d81c0e56871d595d7027a72f94c0aa57ec11251ab7458b76338b80fb6f756a700828064d266aaf8084298cc84e77ab5
data/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.12] - 2026-04-04
4
+
5
+ ### Changed
6
+ - Reposition ruby-mana as a pure embedded LLM engine; agent features (chat, memory, compaction) moved to ruby-claw
7
+ - Rename `Mana::Memory` to `Mana::Context`; `short_term` to `messages`
8
+ - Remove `incognito` mechanism from mana (moved to claw layer)
9
+ - Remove `remember` tool and `REMEMBER_TOOL` constant (now registered by claw via tool interface)
10
+ - Remove long-term memory injection from prompt builder
11
+ - Refactor `tool_handler.rb`: case/when dispatch replaced with `BUILTIN_TOOLS` + `send("handle_#{name}")` dispatch map
12
+ - Update `eval` tool description to emphasize "define new methods/classes/require" role
13
+ - `config.memory_class` renamed to `config.context_class`
14
+
15
+ ### Added
16
+ - `Mana.register_tool(definition, &handler)` — external tool registration interface
17
+ - `Mana.register_prompt_section(&block)` — external prompt injection interface
18
+ - `Mana.tool_handlers` — access registered tool handler map
19
+ - `BUILTIN_TOOLS` constant in ToolHandler for dispatch map
20
+
21
+ ### Removed
22
+ - `Mana.incognito` / `Context.incognito?` / `Context.incognito` — incognito is now claw's responsibility
23
+ - `REMEMBER_TOOL` constant
24
+ - `@incognito` instance variable from Engine
25
+ - Long-term memory (`long_term`, `remember`, `forget`) from Context
26
+
3
27
  ## [0.5.11] - 2026-03-27
4
28
 
5
29
  ### Changed
data/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/ruby-mana.svg)](https://rubygems.org/gems/ruby-mana) · [Website](https://twokidscarl.github.io/ruby-mana/) · [RubyGems](https://rubygems.org/gems/ruby-mana) · [GitHub](https://github.com/twokidsCarl/ruby-mana)
4
4
 
5
+ **Looking for interactive chat, persistent memory, and agent features?** See [ruby-claw](https://github.com/twokidsCarl/ruby-claw).
6
+
5
7
  Embed LLM as native Ruby. Write natural language, it just runs. Not an API wrapper — a language construct that weaves LLM into your code.
6
8
 
7
9
  ```ruby
@@ -188,51 +190,6 @@ lint = ->(code) { ~"check #{code} for style issues, store in <issues>" }
188
190
 
189
191
  Each nested call gets its own conversation context. The outer LLM only sees the function's return value, keeping its context clean.
190
192
 
191
- ### Memory
192
-
193
- Mana has two types of memory:
194
-
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
-
198
- ```ruby
199
- ~"translate <text1> to Japanese, store in <result1>"
200
- ~"translate <text2> to the same language, store in <result2>" # remembers "Japanese"
201
-
202
- ~"remember that the user prefers concise output"
203
- # persists to .mana/ — available in future script runs
204
- ```
205
-
206
- ```ruby
207
- Mana.memory.short_term # view conversation history
208
- Mana.memory.long_term # view persisted facts
209
- Mana.memory.forget(id: 2) # remove a specific fact
210
- Mana.memory.clear! # clear everything
211
- ```
212
-
213
- #### Compaction
214
-
215
- When conversation history grows large, Mana automatically compacts old messages into summaries:
216
-
217
- ```ruby
218
- Mana.configure do |c|
219
- c.memory_pressure = 0.7 # compact when tokens > 70% of context window
220
- c.memory_keep_recent = 4 # keep last 4 rounds, summarize the rest
221
- c.compact_model = nil # nil = use main model for summarization
222
- c.on_compact = ->(summary) { puts "Compacted: #{summary}" }
223
- end
224
- ```
225
-
226
- #### Incognito mode
227
-
228
- Run without any memory — nothing is loaded or saved:
229
-
230
- ```ruby
231
- Mana.incognito do
232
- ~"translate <text>" # no memory, no persistence
233
- end
234
- ```
235
-
236
193
  ## Configuration
237
194
 
238
195
  All options can be set via environment variables (`.env` file) or `Mana.configure`:
@@ -267,16 +224,13 @@ Mana.configure do |c|
267
224
  c.api_key = "sk-..."
268
225
  c.verbose = true
269
226
  c.timeout = 120
270
-
271
- # Memory settings
227
+ c.max_iterations = 20 # max tool-call rounds per prompt
272
228
  c.namespace = "my-project" # nil = auto-detect from git/pwd
273
229
  c.context_window = 128_000 # default: 128_000
274
- c.memory_pressure = 0.7 # compact when tokens exceed 70% of context window
275
- c.memory_keep_recent = 4 # keep last 4 rounds during compaction
276
- c.compact_model = nil # nil = use main model for compaction
277
230
  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)
231
+ c.memory_path = ".mana" # directory for memory files
232
+ c.context_class = nil # custom context class (e.g. from agent frameworks)
233
+ c.knowledge_provider = nil # custom knowledge provider
280
234
  end
281
235
  ```
282
236
 
@@ -345,7 +299,6 @@ Unmatched prompts raise `Mana::MockError` with a helpful message suggesting the
345
299
  numbers = [1, 2, 3]
346
300
  ~"average of <numbers>, ──→ system prompt:
347
301
  store in <result>" - rules + tools
348
- - memory (short/long-term)
349
302
  - variables: numbers = [1,2,3]
350
303
  - available functions
351
304
 
@@ -365,14 +318,12 @@ Unmatched prompts raise `Mana::MockError` with a helpful message suggesting the
365
318
 
366
319
  2. **Build context** — parses `<var>` references from the prompt, reads their current values, discovers available functions via Prism AST (with YARD descriptions if present).
367
320
 
368
- 3. **Build system prompt** — assembles rules, memory (short-term conversation + long-term facts + compaction summaries), variable values, and function signatures into a single system prompt.
321
+ 3. **Build system prompt** — assembles rules, variable values, and function signatures into a single system prompt.
369
322
 
370
- 4. **LLM tool-calling loop** — sends prompt to the LLM with built-in tools (`read_var`, `write_var`, `read_attr`, `write_attr`, `call_func`, `done`, `error`, `eval`, `think`, `knowledge`, `remember`). The LLM responds with tool calls, Mana executes them against the live Ruby binding, and sends results back. This loops until `done` is called or no more tool calls are returned.
323
+ 4. **LLM tool-calling loop** — sends prompt to the LLM with built-in tools (`read_var`, `write_var`, `read_attr`, `write_attr`, `call_func`, `done`, `error`, `eval`, `think`). The LLM responds with tool calls, Mana executes them against the live Ruby binding, and sends results back. This loops until `done` is called or no more tool calls are returned.
371
324
 
372
325
  5. **Return value** — single `write_var` returns the value directly; multiple writes return a Hash. On Ruby 4.0+, a singleton method fallback ensures variables are accessible in the caller's scope.
373
326
 
374
- 6. **Background compaction** — if short-term memory exceeds the token pressure threshold, old messages are summarized by the LLM in a background thread and replaced with a compact summary.
375
-
376
327
 
377
328
  ## License
378
329
 
data/lib/mana/config.rb CHANGED
@@ -13,9 +13,14 @@ module Mana
13
13
  attr_accessor :model, :temperature, :api_key, :max_iterations, :base_url,
14
14
  :backend, :verbose,
15
15
  :namespace, :memory_store, :memory_path,
16
- :context_window
16
+ :context_window,
17
+ :context_class, :knowledge_provider
17
18
  attr_reader :timeout
18
19
 
20
+ # Backward compatibility aliases
21
+ alias_method :memory_class, :context_class
22
+ alias_method :memory_class=, :context_class=
23
+
19
24
  DEFAULT_ANTHROPIC_URL = "https://api.anthropic.com"
20
25
  DEFAULT_OPENAI_URL = "https://api.openai.com"
21
26
 
@@ -36,6 +41,8 @@ module Mana
36
41
  @memory_store = nil
37
42
  @memory_path = nil
38
43
  @context_window = 128_000
44
+ @context_class = nil # nil = use Mana::Context; set to custom class
45
+ @knowledge_provider = nil # nil = use Mana::Knowledge; set to custom module with .query(topic)
39
46
  end
40
47
 
41
48
  # Set timeout; must be a positive number
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mana
4
+ class Context
5
+ attr_reader :messages, :summaries
6
+
7
+ # Initialize with empty conversation context
8
+ def initialize
9
+ @messages = []
10
+ @summaries = []
11
+ end
12
+
13
+ # --- Class methods ---
14
+
15
+ class << self
16
+ # Return the current thread's context instance (lazy-initialized).
17
+ # Uses config.context_class if set, otherwise Mana::Context.
18
+ def current
19
+ Thread.current[:mana_context] ||= begin
20
+ klass = Mana.config.context_class || self
21
+ klass.new
22
+ end
23
+ end
24
+ end
25
+
26
+ # --- Token estimation ---
27
+
28
+ # Estimate total token count across short-term messages and summaries.
29
+ def token_count
30
+ count = 0
31
+ @messages.each do |msg|
32
+ content = msg[:content]
33
+ case content
34
+ when String
35
+ # Plain text message
36
+ count += estimate_tokens(content)
37
+ when Array
38
+ # Array of content blocks (tool_use, tool_result, text)
39
+ content.each do |block|
40
+ count += estimate_tokens(block[:text] || block[:content] || "")
41
+ end
42
+ end
43
+ end
44
+ @summaries.each { |s| count += estimate_tokens(s) }
45
+ count
46
+ end
47
+
48
+ # Rough token estimate: ~4 characters per token
49
+ def estimate_tokens(text)
50
+ return 0 unless text.is_a?(String)
51
+
52
+ (text.length / 4.0).ceil
53
+ end
54
+
55
+ # --- Context management ---
56
+
57
+ # Clear conversation history and summaries
58
+ def clear!
59
+ clear_messages!
60
+ end
61
+
62
+ # Clear conversation history and compaction summaries
63
+ def clear_messages!
64
+ @messages.clear
65
+ @summaries.clear
66
+ end
67
+
68
+ # --- Display ---
69
+
70
+ # Human-readable summary: counts and token usage
71
+ def inspect
72
+ "#<Mana::Context messages=#{messages_rounds} rounds, tokens=#{token_count}/#{context_window}>"
73
+ end
74
+
75
+ private
76
+
77
+ # Count conversation rounds (user-prompt messages only, not tool results)
78
+ def messages_rounds
79
+ @messages.count { |m| m[:role] == "user" && m[:content].is_a?(String) }
80
+ end
81
+
82
+ def context_window
83
+ Mana.config.context_window
84
+ end
85
+ end
86
+ end
data/lib/mana/engine.rb CHANGED
@@ -96,7 +96,7 @@ module Mana
96
96
  },
97
97
  {
98
98
  name: "eval",
99
- description: "Execute Ruby code directly in the caller's binding. Returns the result of the last expression. Use this for anything that's easier to express as Ruby code than as individual tool calls.",
99
+ description: "Define new methods, classes, or require libraries use this to create new things in the runtime. For reading/writing variables and calling existing functions, use the other tools.",
100
100
  input_schema: {
101
101
  type: "object",
102
102
  properties: {
@@ -118,18 +118,6 @@ module Mana
118
118
  }
119
119
  ].freeze
120
120
 
121
- # Separated from TOOLS because it's conditionally excluded in incognito mode
122
- REMEMBER_TOOL = {
123
- name: "remember",
124
- description: "Store a fact in long-term memory. This memory persists across script executions. Use when the user explicitly asks to remember something.",
125
- input_schema: {
126
- type: "object",
127
- properties: { content: { type: "string", description: "The fact to remember" } },
128
- required: ["content"]
129
- }
130
- }.freeze
131
-
132
-
133
121
  class << self
134
122
  # Entry point for ~"..." prompts. Routes to mock handler or real LLM engine.
135
123
  def run(prompt, caller_binding)
@@ -141,53 +129,47 @@ module Mana
141
129
  new(caller_binding).execute(prompt)
142
130
  end
143
131
 
144
- # Built-in tools + remember (conditional)
132
+ # Built-in tools + registered tools (e.g. remember from claw)
145
133
  def all_tools
146
- tools = TOOLS.dup
147
- tools << REMEMBER_TOOL unless Memory.incognito?
148
- tools
134
+ TOOLS.dup + Mana.registered_tools
149
135
  end
150
136
 
151
- # Query the runtime knowledge base
137
+ # Query the runtime knowledge base.
138
+ # Uses config.knowledge_provider if set, otherwise Mana::Knowledge.
152
139
  def knowledge(topic)
153
- Mana::Knowledge.query(topic)
140
+ provider = Mana.config.knowledge_provider || Mana::Knowledge
141
+ provider.query(topic)
154
142
  end
155
143
  end
156
144
 
157
- # Capture the caller's binding, config, source path, and incognito state
145
+ # Capture the caller's binding, config, and source path
158
146
  def initialize(caller_binding, config = Mana.config)
159
147
  @binding = caller_binding
160
148
  @config = config
161
149
  @caller_path = caller_source_path
162
- @incognito = Memory.incognito?
163
150
  end
164
151
 
165
152
  # Main execution loop: build context, call LLM, handle tool calls, iterate until done.
166
153
  # Optional &on_text block receives streaming text deltas for real-time display.
167
154
  def execute(prompt, &on_text)
168
- # Track nesting depth to isolate memory for nested ~"..." calls
155
+ # Track nesting depth to isolate context for nested ~"..." calls
169
156
  Thread.current[:mana_depth] ||= 0
170
157
  Thread.current[:mana_depth] += 1
171
158
  nested = Thread.current[:mana_depth] > 1
172
- outer_memory = nil # defined here so ensure block always has access
173
-
174
- # Nested calls get fresh short-term memory but share long-term
175
- if nested && !@incognito
176
- outer_memory = Thread.current[:mana_memory]
177
- inner_memory = Mana::Memory.new
178
- long_term = outer_memory&.long_term || []
179
- inner_memory.instance_variable_set(:@long_term, long_term)
180
- inner_memory.instance_variable_set(:@next_id, (long_term.map { |m| m[:id] }.max || 0) + 1)
181
- Thread.current[:mana_memory] = inner_memory
159
+ outer_context = nil # defined here so ensure block always has access
160
+
161
+ # Nested calls get fresh short-term context
162
+ if nested
163
+ outer_context = Thread.current[:mana_context]
164
+ Thread.current[:mana_context] = Mana::Context.new
182
165
  end
183
166
 
184
167
  # Extract <var> references from the prompt and read their current values
185
168
  context = build_context(prompt)
186
169
  system_prompt = build_system_prompt(context)
187
170
 
188
- memory = @incognito ? nil : Memory.current
189
-
190
- messages = memory ? memory.short_term : []
171
+ memory = Context.current
172
+ messages = memory.messages
191
173
 
192
174
  # Strip trailing unpaired tool_use messages from prior calls.
193
175
  # Both Anthropic and OpenAI reject requests where the last assistant message
@@ -251,7 +233,7 @@ module Mana
251
233
  on_text.call(:tool_start, tu[:name], tu[:input])
252
234
  end
253
235
  end
254
- result = handle_effect(tu, memory)
236
+ result = handle_effect(tu)
255
237
  if on_text && !%w[done error].include?(tu[:name])
256
238
  on_text.call(:tool_end, tu[:name], result)
257
239
  end
@@ -266,7 +248,7 @@ module Mana
266
248
  end
267
249
 
268
250
  # Append a final assistant summary so LLM has full context next call
269
- if memory && done_result
251
+ if done_result
270
252
  messages << { role: "assistant", content: [{ type: "text", text: "Done: #{done_result}" }] }
271
253
  end
272
254
 
@@ -284,14 +266,14 @@ module Mana
284
266
  rescue => e
285
267
  # Rollback: remove messages added during this failed call so they don't
286
268
  # pollute short-term memory for subsequent prompts
287
- if memory && messages.size > messages_start_size
269
+ if messages.size > messages_start_size
288
270
  messages.slice!(messages_start_size..)
289
271
  end
290
272
  raise e
291
273
  ensure
292
- # Restore outer memory when exiting a nested call
293
- if nested && !@incognito
294
- Thread.current[:mana_memory] = outer_memory
274
+ # Restore outer context when exiting a nested call
275
+ if nested
276
+ Thread.current[:mana_context] = outer_context
295
277
  end
296
278
  Thread.current[:mana_depth] -= 1 if Thread.current[:mana_depth]
297
279
  end
@@ -322,13 +304,11 @@ module Mana
322
304
  write_local(name.to_s, value)
323
305
  end
324
306
 
325
- # Record in short-term memory if not incognito
326
- if !@incognito
327
- memory = Memory.current
328
- if memory
329
- memory.short_term << { role: "user", content: prompt }
330
- memory.short_term << { role: "assistant", content: [{ type: "text", text: "Done: #{return_value || values.inspect}" }] }
331
- end
307
+ # Record in context
308
+ memory = Context.current
309
+ if memory
310
+ memory.messages << { role: "user", content: prompt }
311
+ memory.messages << { role: "assistant", content: [{ type: "text", text: "Done: #{return_value || values.inspect}" }] }
332
312
  end
333
313
 
334
314
  # Return _return value if set, otherwise the first written value
@@ -119,25 +119,13 @@ module Mana
119
119
  end
120
120
 
121
121
  def memory
122
- store_class = Mana.config.memory_store&.class&.name || "Mana::FileStore (default)"
123
- path = if Mana.config.memory_path
124
- Mana.config.memory_path
125
- else
126
- "~/.mana/memory/<namespace>.json"
127
- end
128
-
129
122
  <<~TEXT
130
- ruby-mana has two types of memory:
131
- - Short-term memory: conversation history within the current process. Each ~"..."
123
+ ruby-mana manages conversation context via Mana::Context:
124
+ - Short-term context: conversation history within the current process. Each ~"..."
132
125
  call appends to it, so consecutive calls share context. Cleared when the process exits.
133
- - Long-term memory: persistent facts stored on disk as JSON files.
134
- Default path: #{path}
135
- Current store: #{store_class}
136
- Namespace is auto-detected from the git repo name, Gemfile directory, or cwd.
137
- Configurable via: Mana.configure { |c| c.memory_path = "/custom/path" }
138
- Or provide a custom MemoryStore subclass for Redis, DB, etc.
139
- - Incognito mode: Mana.incognito { ~"..." } disables all memory.
140
- The LLM can store facts via the `remember` tool. These persist across script executions.
126
+ - Summaries: compacted conversation summaries from prior rounds.
127
+ Long-term memory and the `remember` tool are provided by agent frameworks (e.g. ruby-claw)
128
+ via Mana's tool registration interface.
141
129
  TEXT
142
130
  end
143
131
 
data/lib/mana/memory.rb CHANGED
@@ -17,11 +17,15 @@ module Mana
17
17
 
18
18
  class << self
19
19
  # Return the current thread's memory instance (lazy-initialized).
20
+ # Uses config.memory_class if set (e.g. Claw::Memory), otherwise Mana::Memory.
20
21
  # Returns nil in incognito mode.
21
22
  def current
22
23
  return nil if incognito?
23
24
 
24
- Thread.current[:mana_memory] ||= new
25
+ Thread.current[:mana_memory] ||= begin
26
+ klass = Mana.config.memory_class || self
27
+ klass.new
28
+ end
25
29
  end
26
30
 
27
31
  # Check if the current thread is in incognito mode (no memory)
@@ -34,37 +34,27 @@ module Mana
34
34
  "- done(result: ...) to return a value. error(message: ...) only after you have tried and failed.",
35
35
  "- <var> references point to variables in scope; create with write_var if missing.",
36
36
  "- Match types precisely: numbers for numeric values, arrays for lists, strings for text.",
37
+ "- eval to define new methods, new classes, or require libraries. For operating on existing variables and functions, use read_var/write_var/call_func.",
37
38
  "- Current prompt overrides conversation history and memories.",
38
39
  ]
39
40
 
40
- if @incognito
41
- parts << ""
42
- parts << "You are in incognito mode. The remember tool is disabled. No memories will be loaded or saved."
43
- else
44
- memory = Memory.current
45
- # Inject memory context when available
46
- if memory
47
- # Add compaction summaries from prior conversations
48
- unless memory.summaries.empty?
49
- parts << ""
50
- parts << "Previous conversation summary:"
51
- memory.summaries.each { |s| parts << " #{s}" }
52
- end
53
-
54
- # Add persistent long-term facts
55
- unless memory.long_term.empty?
56
- parts << ""
57
- parts << "Long-term memories (persistent background context):"
58
- memory.long_term.each { |m| parts << "- #{m[:content]}" }
59
- end
60
-
61
- unless memory.long_term.empty?
62
- parts << ""
63
- parts << "You have a `remember` tool to store new facts in long-term memory when the user asks."
64
- end
41
+ mana_ctx = Context.current
42
+ # Inject context when available
43
+ if mana_ctx
44
+ # Add compaction summaries from prior conversations
45
+ unless mana_ctx.summaries.empty?
46
+ parts << ""
47
+ parts << "Previous conversation summary:"
48
+ mana_ctx.summaries.each { |s| parts << " #{s}" }
65
49
  end
66
50
  end
67
51
 
52
+ # Inject registered prompt sections (e.g. long-term memories from claw)
53
+ Mana.prompt_sections.each do |section_block|
54
+ text = section_block.call
55
+ parts << "" << text if text && !text.empty?
56
+ end
57
+
68
58
  # Inject current variable values referenced in the prompt
69
59
  unless context.empty?
70
60
  parts << ""
@@ -135,7 +125,7 @@ module Mana
135
125
 
136
126
  # User-defined classes/modules (skip Ruby internals)
137
127
  skip = [Object, Kernel, BasicObject, Module, Class, Mana, Mana::Engine,
138
- Mana::Memory, Mana::Config]
128
+ Mana::Memory, Mana::Context, Mana::Config]
139
129
  user_classes = ObjectSpace.each_object(Class)
140
130
  .reject { |c| c.name.nil? || c.name.start_with?("Mana::") || c.name.start_with?("#<") }
141
131
  .reject { |c| skip.include?(c) }
@@ -3,82 +3,27 @@
3
3
  module Mana
4
4
  # Dispatches LLM tool calls to their respective handlers.
5
5
  # Mixed into Engine as a private method.
6
+ #
7
+ # Built-in tools are dispatched via instance methods (handle_<name>),
8
+ # which can access @binding, @written_vars, etc.
9
+ # External tools (registered via Mana.register_tool) are dispatched via Procs
10
+ # that only receive input — they cannot access the engine's binding.
6
11
  module ToolHandler
12
+ BUILTIN_TOOLS = %w[read_var write_var read_attr write_attr call_func eval knowledge done error].freeze
13
+
7
14
  private
8
15
 
9
16
  # Dispatch a single tool call from the LLM.
10
- def handle_effect(tool_use, memory = nil)
17
+ def handle_effect(tool_use)
11
18
  name = tool_use[:name]
12
19
  input = tool_use[:input] || {}
13
20
  # Normalize keys to strings for consistent access
14
21
  input = input.transform_keys(&:to_s) if input.is_a?(Hash)
15
22
 
16
- case name
17
- when "read_var"
18
- # Read a variable from the caller's binding and return its serialized value
19
- val = serialize_value(resolve(input["name"]))
20
- vlog_value(" ↩ #{input['name']} =", val)
21
- val
22
-
23
- when "write_var"
24
- # Write a value to the caller's binding and track it for the return value
25
- var_name = input["name"]
26
- value = input["value"]
27
- write_local(var_name, value)
28
- @written_vars[var_name] = value
29
- vlog_value(" ✅ #{var_name} =", value)
30
- "ok: #{var_name} = #{value.inspect}"
31
-
32
- when "read_attr"
33
- # Read an attribute (public method) from a Ruby object in scope
34
- obj = resolve(input["obj"])
35
- validate_name!(input["attr"])
36
- serialize_value(obj.public_send(input["attr"]))
37
-
38
- when "write_attr"
39
- # Set an attribute (public setter) on a Ruby object in scope
40
- obj = resolve(input["obj"])
41
- validate_name!(input["attr"])
42
- obj.public_send("#{input['attr']}=", input["value"])
43
- "ok: #{input['obj']}.#{input['attr']} = #{input['value'].inspect}"
44
-
45
- when "call_func"
46
- handle_call_func(input)
47
-
48
- when "knowledge"
49
- # Look up information about ruby-mana from the knowledge base
50
- self.class.knowledge(input["topic"])
51
-
52
- when "remember"
53
- # Store a fact in long-term memory (persistent across executions)
54
- if @incognito
55
- "Memory not saved (incognito mode)"
56
- elsif memory
57
- entry = memory.remember(input["content"])
58
- "Remembered (id=#{entry[:id]}): #{input['content']}"
59
- else
60
- "Memory not available"
61
- end
62
-
63
- when "done"
64
- # Signal task completion; the result becomes the return value
65
- done_val = input["result"]
66
- vlog_value("🏁 Done:", done_val)
67
- vlog("═" * 60)
68
- input["result"].to_s
69
-
70
- when "error"
71
- # LLM signals it cannot complete the task — raise as exception
72
- msg = input["message"] || "LLM reported an error"
73
- vlog("❌ Error: #{msg}")
74
- vlog("═" * 60)
75
- raise Mana::LLMError, msg
76
-
77
- when "eval"
78
- result = @binding.eval(input["code"])
79
- vlog_value(" ↩ eval →", result)
80
- serialize_value(result)
81
-
23
+ if BUILTIN_TOOLS.include?(name)
24
+ send("handle_#{name}", input)
25
+ elsif (handler = Mana.tool_handlers[name])
26
+ handler.call(input)
82
27
  else
83
28
  "error: unknown tool #{name}"
84
29
  end
@@ -91,6 +36,62 @@ module Mana
91
36
  "error: #{e.class}: #{e.message}"
92
37
  end
93
38
 
39
+ # --- Built-in tool handlers ---
40
+
41
+ def handle_read_var(input)
42
+ val = serialize_value(resolve(input["name"]))
43
+ vlog_value(" ↩ #{input['name']} =", val)
44
+ val
45
+ end
46
+
47
+ def handle_write_var(input)
48
+ var_name = input["name"]
49
+ value = input["value"]
50
+ write_local(var_name, value)
51
+ @written_vars[var_name] = value
52
+ vlog_value(" ✅ #{var_name} =", value)
53
+ "ok: #{var_name} = #{value.inspect}"
54
+ end
55
+
56
+ def handle_read_attr(input)
57
+ obj = resolve(input["obj"])
58
+ validate_name!(input["attr"])
59
+ serialize_value(obj.public_send(input["attr"]))
60
+ end
61
+
62
+ def handle_write_attr(input)
63
+ obj = resolve(input["obj"])
64
+ validate_name!(input["attr"])
65
+ obj.public_send("#{input['attr']}=", input["value"])
66
+ "ok: #{input['obj']}.#{input['attr']} = #{input['value'].inspect}"
67
+ end
68
+
69
+ def handle_knowledge(input)
70
+ self.class.knowledge(input["topic"])
71
+ end
72
+
73
+ def handle_done(input)
74
+ done_val = input["result"]
75
+ vlog_value("🏁 Done:", done_val)
76
+ vlog("═" * 60)
77
+ input["result"].to_s
78
+ end
79
+
80
+ def handle_error(input)
81
+ msg = input["message"] || "LLM reported an error"
82
+ vlog("❌ Error: #{msg}")
83
+ vlog("═" * 60)
84
+ raise Mana::LLMError, msg
85
+ end
86
+
87
+ def handle_eval(input)
88
+ result = @binding.eval(input["code"])
89
+ vlog_value(" ↩ eval →", result)
90
+ serialize_value(result)
91
+ end
92
+
93
+ # --- call_func and helpers ---
94
+
94
95
  # Handle call_func tool: chained calls, block bodies, simple calls
95
96
  def handle_call_func(input)
96
97
  func = input["name"]
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.11"
4
+ VERSION = "0.5.12"
5
5
  end
data/lib/mana.rb CHANGED
@@ -6,7 +6,8 @@ require_relative "mana/backends/base"
6
6
  require_relative "mana/backends/anthropic"
7
7
  require_relative "mana/backends/openai"
8
8
  require_relative "mana/memory_store"
9
- require_relative "mana/memory"
9
+ require_relative "mana/memory" # kept for backward compatibility until claw migrates
10
+ require_relative "mana/context"
10
11
  require_relative "mana/logger"
11
12
  require_relative "mana/knowledge"
12
13
  require_relative "mana/binding_helpers"
@@ -42,21 +43,62 @@ module Mana
42
43
  config.model = model
43
44
  end
44
45
 
45
- # Reset all global state: config, thread-local memory and mock
46
+ # Reset all global state: config, thread-local context and mock
46
47
  def reset!
47
48
  @config = Config.new
48
- Thread.current[:mana_memory] = nil
49
+ Thread.current[:mana_context] = nil
50
+ Thread.current[:mana_memory] = nil # backward compat for claw transition
49
51
  Thread.current[:mana_mock] = nil
52
+ clear_tools!
50
53
  end
51
54
 
52
- # Access current thread's memory
55
+ # Access current thread's context (public API kept as `memory` for backward compat)
53
56
  def memory
54
- Memory.current
57
+ Context.current
55
58
  end
56
59
 
57
- # Run a block in incognito mode (no memory)
58
- def incognito(&block)
59
- Memory.incognito(&block)
60
+ # --- Tool registration ---
61
+
62
+ # Register an external tool definition with its handler block.
63
+ # tool_definition is a hash with :name, :description, :input_schema.
64
+ # The handler block receives (input) and returns a result string.
65
+ def register_tool(tool_definition, &handler)
66
+ @registered_tools ||= []
67
+ @tool_handlers ||= {}
68
+ @registered_tools << tool_definition
69
+ @tool_handlers[tool_definition[:name]] = handler
70
+ end
71
+
72
+ # Return a copy of the registered tool definitions
73
+ def registered_tools
74
+ @registered_tools ||= []
75
+ @registered_tools.dup
76
+ end
77
+
78
+ # Return the name → handler mapping for registered tools
79
+ def tool_handlers
80
+ @tool_handlers ||= {}
81
+ end
82
+
83
+ # Clear all registered tools, handlers, and prompt sections
84
+ def clear_tools!
85
+ @registered_tools = []
86
+ @tool_handlers = {}
87
+ @prompt_sections = []
88
+ end
89
+
90
+ # --- Prompt section registration ---
91
+
92
+ # Register a block that returns text to inject into the system prompt.
93
+ # The block is called each time a prompt is built. Return nil or "" to skip.
94
+ def register_prompt_section(&block)
95
+ @prompt_sections ||= []
96
+ @prompt_sections << block
97
+ end
98
+
99
+ # Return the list of registered prompt section blocks
100
+ def prompt_sections
101
+ @prompt_sections ||= []
60
102
  end
61
103
 
62
104
  # View generated source for a mana-compiled method
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.11
4
+ version: 0.5.12
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carl Li
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-29 00:00:00.000000000 Z
11
+ date: 2026-04-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: binding_of_caller
@@ -57,6 +57,7 @@ files:
57
57
  - lib/mana/binding_helpers.rb
58
58
  - lib/mana/compiler.rb
59
59
  - lib/mana/config.rb
60
+ - lib/mana/context.rb
60
61
  - lib/mana/engine.rb
61
62
  - lib/mana/introspect.rb
62
63
  - lib/mana/knowledge.rb