ruby-claw 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8f684f9fcb5e1d23a2b2ca80e604ecd3b92791220344f6bd708c1d7e9af42821
4
+ data.tar.gz: bbaf22db21623d2956702b9288638f01a10c57b25e2bb8f1f12f81fe4f9a9cba
5
+ SHA512:
6
+ metadata.gz: d154cae92ba1eeca638823306e4641151f6fd9921407e8474b669d4fb6e9ae9fbb7f357229f36bb2073565f798563ab85720ff8d7a0ec7f27f85efc76806f350
7
+ data.tar.gz: 899e0ed9397bed0238ee2f2a03439815ed55b311552b81e06387d8d98722c3ada913504e1d3766cc96b56c03c8f8cbb96231d4e71404709fb44a2654795fbce9
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Carl Li
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # ruby-claw
2
+
3
+ Agent framework for Ruby, built on [ruby-mana](https://github.com/twokidsCarl/ruby-mana).
4
+
5
+ Claw extends mana's LLM engine with interactive chat, persistent memory with compaction, session persistence, and runtime state serialization.
6
+
7
+ ## Install
8
+
9
+ ```ruby
10
+ gem "ruby-claw"
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```ruby
16
+ require "claw"
17
+
18
+ # Start interactive chat
19
+ Claw.chat
20
+
21
+ # Access enhanced memory
22
+ Claw.memory.search("ruby")
23
+ Claw.memory.save_session
24
+
25
+ # Configure
26
+ Claw.configure do |c|
27
+ c.memory_pressure = 0.7
28
+ c.persist_session = true
29
+ end
30
+ ```
31
+
32
+ ## Components
33
+
34
+ - **Claw::Chat** — interactive REPL with streaming markdown output
35
+ - **Claw::Memory** — compaction, search, and session persistence on top of Mana::Memory
36
+ - **Claw::Serializer** — save/restore runtime variables and method definitions
37
+ - **Claw::Knowledge** — extended knowledge base with agent-specific topics
38
+
39
+ ## License
40
+
41
+ MIT
data/exe/claw ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "dotenv/load" rescue nil
5
+ require "claw"
6
+
7
+ Claw.chat
data/lib/claw/chat.rb ADDED
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Claw
4
+ # Interactive chat mode — enter with Claw.chat to talk to the agent 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[36mclaw>\e[0m " # cyan
9
+ CLAW_PREFIX = "\e[33mclaw>\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
+ HISTORY_FILE = File.join(Dir.home, ".claw_history")
24
+ HISTORY_MAX = 1000
25
+
26
+ def self.start(caller_binding)
27
+ require "reline"
28
+ load_history
29
+ puts "#{DIM}Claw agent · type 'exit' to quit#{RESET}"
30
+ puts
31
+
32
+ loop do
33
+ input = read_input
34
+ break if input.nil?
35
+ next if input.strip.empty?
36
+ break if input.strip.match?(EXIT_COMMANDS)
37
+
38
+ if input.start_with?("!")
39
+ eval_ruby(caller_binding, input[1..].strip)
40
+ elsif ruby_syntax?(input)
41
+ eval_ruby(caller_binding, input) { run_claw(caller_binding, input) }
42
+ else
43
+ run_claw(caller_binding, input)
44
+ end
45
+ puts
46
+ end
47
+
48
+ # Save session on exit
49
+ Claw.memory&.save_session
50
+ save_history
51
+ puts "#{DIM}bye!#{RESET}"
52
+ end
53
+
54
+ def self.load_history
55
+ return unless File.exist?(HISTORY_FILE)
56
+
57
+ File.readlines(HISTORY_FILE, chomp: true).last(HISTORY_MAX).each do |line|
58
+ Reline::HISTORY << line
59
+ end
60
+ rescue StandardError
61
+ # ignore corrupt history
62
+ end
63
+ private_class_method :load_history
64
+
65
+ def self.save_history
66
+ lines = Reline::HISTORY.to_a.last(HISTORY_MAX)
67
+ File.write(HISTORY_FILE, lines.join("\n") + "\n")
68
+ rescue StandardError
69
+ # ignore write failures
70
+ end
71
+ private_class_method :save_history
72
+
73
+ def self.read_input
74
+ buffer = Reline.readline(USER_PROMPT, true)
75
+ return nil if buffer.nil?
76
+
77
+ while incomplete_ruby?(buffer)
78
+ line = Reline.readline(CONT_PROMPT, false)
79
+ break if line.nil?
80
+ buffer += "\n" + line
81
+ end
82
+ buffer
83
+ end
84
+ private_class_method :read_input
85
+
86
+ def self.ruby_syntax?(input)
87
+ RubyVM::InstructionSequence.compile(input)
88
+ true
89
+ rescue SyntaxError
90
+ false
91
+ end
92
+ private_class_method :ruby_syntax?
93
+
94
+ def self.incomplete_ruby?(code)
95
+ RubyVM::InstructionSequence.compile(code)
96
+ false
97
+ rescue SyntaxError => e
98
+ e.message.include?("unexpected end-of-input") ||
99
+ e.message.include?("unterminated")
100
+ end
101
+ private_class_method :incomplete_ruby?
102
+
103
+ def self.eval_ruby(caller_binding, code)
104
+ result = caller_binding.eval(code)
105
+ puts "#{RUBY_PREFIX}#{result.inspect}"
106
+ rescue NameError, NoMethodError => e
107
+ block_given? ? yield : puts("#{ERROR_COLOR}#{e.class}: #{e.message}#{RESET}")
108
+ rescue => e
109
+ puts "#{ERROR_COLOR}#{e.class}: #{e.message}#{RESET}"
110
+ end
111
+ private_class_method :eval_ruby
112
+
113
+ # --- LLM execution with streaming + markdown rendering ---
114
+
115
+ def self.run_claw(caller_binding, input)
116
+ streaming_text = false
117
+ in_code_block = false
118
+ line_buffer = +""
119
+ engine = Mana::Engine.new(caller_binding)
120
+
121
+ begin
122
+ result = engine.execute(input) do |type, *args|
123
+ case type
124
+ when :text
125
+ unless streaming_text
126
+ print CLAW_PREFIX
127
+ streaming_text = true
128
+ end
129
+
130
+ line_buffer << args[0].to_s
131
+ while (idx = line_buffer.index("\n"))
132
+ line = line_buffer.slice!(0, idx + 1)
133
+ in_code_block = render_line(line.chomp, in_code_block)
134
+ puts
135
+ end
136
+
137
+ when :tool_start
138
+ flush_line_buffer(line_buffer, in_code_block) if streaming_text
139
+ streaming_text = false
140
+ in_code_block = false
141
+ line_buffer.clear
142
+ name, input_data = args
143
+ detail = format_tool_call(name, input_data)
144
+ puts "#{TOOL_COLOR} ⚡ #{detail}#{RESET}"
145
+
146
+ when :tool_end
147
+ name, result_str = args
148
+ summary = truncate(result_str.to_s, 120)
149
+ puts "#{RESULT_COLOR} ↩ #{summary}#{RESET}" unless summary.start_with?("ok:")
150
+ end
151
+ end
152
+
153
+ flush_line_buffer(line_buffer, in_code_block) if streaming_text
154
+
155
+ unless streaming_text
156
+ display = case result
157
+ when Hash then result.inspect
158
+ when nil then nil
159
+ when String then render_markdown(result)
160
+ else result.inspect
161
+ end
162
+ puts "#{CLAW_PREFIX}#{display}" if display
163
+ end
164
+
165
+ # Schedule compaction after each exchange
166
+ Claw.memory&.schedule_compaction
167
+ rescue Mana::LLMError, Mana::MaxIterationsError => e
168
+ flush_line_buffer(line_buffer, in_code_block) if streaming_text
169
+ puts "#{ERROR_COLOR}error: #{e.message}#{RESET}"
170
+ end
171
+ end
172
+ private_class_method :run_claw
173
+
174
+ # --- Markdown rendering ---
175
+
176
+ def self.render_line(line, in_code_block)
177
+ if line.strip.start_with?("```")
178
+ if in_code_block
179
+ return false
180
+ else
181
+ return true
182
+ end
183
+ end
184
+
185
+ if in_code_block
186
+ print " #{CODE_COLOR}#{line}#{RESET}"
187
+ else
188
+ print render_markdown_inline(line)
189
+ end
190
+ in_code_block
191
+ end
192
+ private_class_method :render_line
193
+
194
+ def self.flush_line_buffer(buffer, in_code_block)
195
+ return if buffer.empty?
196
+ text = buffer.dup
197
+ buffer.clear
198
+ if in_code_block
199
+ print " #{CODE_COLOR}#{text}#{RESET}"
200
+ else
201
+ print render_markdown_inline(text)
202
+ end
203
+ puts
204
+ end
205
+ private_class_method :flush_line_buffer
206
+
207
+ def self.render_markdown_inline(text)
208
+ text
209
+ .gsub(/\*\*(.+?)\*\*/, "#{BOLD}\\1#{RESET}")
210
+ .gsub(/(?<!`)`([^`]+)`(?!`)/, "#{CODE_COLOR}\\1#{RESET}")
211
+ .gsub(/^\#{1,3}\s+(.+)/) { BOLD + $1 + RESET }
212
+ end
213
+ private_class_method :render_markdown_inline
214
+
215
+ def self.render_markdown(text)
216
+ lines = text.lines
217
+ result = +""
218
+ in_code = false
219
+ lines.each do |line|
220
+ stripped = line.strip
221
+ if stripped.start_with?("```")
222
+ in_code = !in_code
223
+ next
224
+ end
225
+ if in_code
226
+ result << " #{CODE_COLOR}#{line.rstrip}#{RESET}\n"
227
+ else
228
+ result << render_markdown_inline(line.rstrip) << "\n"
229
+ end
230
+ end
231
+ result.chomp
232
+ end
233
+ private_class_method :render_markdown
234
+
235
+ # --- Tool formatting helpers ---
236
+
237
+ def self.format_tool_call(name, input)
238
+ case name
239
+ when "call_func"
240
+ func = input[:name] || input["name"]
241
+ args = input[:args] || input["args"] || []
242
+ body = input[:body] || input["body"]
243
+ desc = func.to_s
244
+ desc += "(#{args.map(&:inspect).join(', ')})" if args.any?
245
+ desc += " { #{truncate(body, 40)} }" if body
246
+ desc
247
+ when "read_var", "write_var"
248
+ var = input[:name] || input["name"]
249
+ val = input[:value] || input["value"]
250
+ val ? "#{var} = #{truncate(val.inspect, 60)}" : var.to_s
251
+ when "read_attr", "write_attr"
252
+ obj = input[:obj] || input["obj"]
253
+ attr = input[:attr] || input["attr"]
254
+ "#{obj}.#{attr}"
255
+ when "remember"
256
+ content = input[:content] || input["content"]
257
+ "remember: #{truncate(content.to_s, 60)}"
258
+ when "knowledge"
259
+ topic = input[:topic] || input["topic"]
260
+ "knowledge(#{topic})"
261
+ else
262
+ name.to_s
263
+ end
264
+ end
265
+ private_class_method :format_tool_call
266
+
267
+ def self.truncate(str, max)
268
+ str.length > max ? "#{str[0, max]}..." : str
269
+ end
270
+ private_class_method :truncate
271
+ end
272
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Claw
4
+ # Claw-specific configuration — extends Mana's config with agent features.
5
+ # Set via Claw.configure { |c| ... }.
6
+ class Config
7
+ attr_accessor :memory_pressure, :memory_keep_recent, :compact_model,
8
+ :on_compact, :persist_session, :memory_top_k
9
+
10
+ def initialize
11
+ @memory_pressure = 0.7
12
+ @memory_keep_recent = 4
13
+ @compact_model = nil
14
+ @on_compact = nil
15
+ @persist_session = true
16
+ @memory_top_k = 10
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Claw
4
+ # Extended knowledge base — adds claw-specific topics, falls back to Mana::Knowledge.
5
+ module Knowledge
6
+ class << self
7
+ # Query a topic: try claw-specific sections first, then delegate to Mana::Knowledge.
8
+ def query(topic)
9
+ topic_key = topic.to_s.strip.downcase
10
+
11
+ # Try claw-specific sections (bidirectional substring match)
12
+ match = claw_sections.find { |k, _| topic_key.include?(k) || k.include?(topic_key) }
13
+ return "[source: claw]\n#{match.last}" if match
14
+
15
+ # Fall back to mana's knowledge base
16
+ Mana::Knowledge.query(topic)
17
+ end
18
+
19
+ private
20
+
21
+ def claw_sections
22
+ {
23
+ "claw" => overview,
24
+ "agent" => overview,
25
+ "compaction" => compaction,
26
+ "session" => session,
27
+ "serializer" => serializer,
28
+ "persistence" => session
29
+ }
30
+ end
31
+
32
+ def overview
33
+ <<~TEXT
34
+ ruby-claw v#{Claw::VERSION} is an Agent framework built on ruby-mana.
35
+ It adds interactive chat, persistent memory with compaction, session persistence,
36
+ knowledge base, and runtime state serialization.
37
+
38
+ Key components:
39
+ - Claw::Chat — interactive REPL with streaming markdown output
40
+ - Claw::Memory — enhanced memory with compaction, search, and session persistence
41
+ - Claw::Serializer — save/restore runtime state across process restarts
42
+ - Claw::Knowledge — extended knowledge base with claw-specific topics
43
+ TEXT
44
+ end
45
+
46
+ def compaction
47
+ <<~TEXT
48
+ Memory compaction in Claw:
49
+ When short-term memory exceeds the token pressure threshold
50
+ (#{Claw.config.memory_pressure}), old messages are summarized by the LLM.
51
+ - schedule_compaction: launches background compaction thread
52
+ - compact!: synchronous compaction
53
+ - needs_compaction?: checks token pressure
54
+ - Keeps the #{Claw.config.memory_keep_recent} most recent conversation rounds
55
+ - Summaries are rolling — merged on each compaction, never accumulate
56
+ Configure via: Claw.configure { |c| c.memory_pressure = 0.7 }
57
+ TEXT
58
+ end
59
+
60
+ def session
61
+ <<~TEXT
62
+ Session persistence in Claw:
63
+ Conversation state (short-term memory + summaries) can be saved to disk
64
+ and restored on next startup, enabling multi-session agents.
65
+ - save_session: writes current state to disk
66
+ - load_session: restores from disk (called automatically on init)
67
+ - Stored as JSON in the sessions/ subdirectory of the memory store
68
+ Configure via: Claw.configure { |c| c.persist_session = true }
69
+ TEXT
70
+ end
71
+
72
+ def serializer
73
+ <<~TEXT
74
+ Runtime state serialization in Claw:
75
+ Claw::Serializer can save and restore local variables and method definitions.
76
+ - Claw::Serializer.save(binding, dir) — saves values.json + definitions.rb
77
+ - Claw::Serializer.restore(binding, dir) — restores from saved files
78
+ - Values: Marshal.dump (hex encoded) with JSON fallback
79
+ - Definitions: tracked via @__claw_definitions__ on the receiver
80
+ TEXT
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Claw
4
+ # Enhanced memory with compaction, session persistence, and search.
5
+ # Inherits base memory management from Mana::Memory and adds agent-level features.
6
+ class Memory < Mana::Memory
7
+ SUMMARIZE_MAX_RETRIES = 3
8
+
9
+ def initialize
10
+ super
11
+ @compact_mutex = Mutex.new
12
+ @compact_thread = nil
13
+ load_session if Claw.config.persist_session
14
+ end
15
+
16
+ # --- Class methods ---
17
+
18
+ class << self
19
+ # Return the current thread's memory instance (lazy-initialized).
20
+ # Returns nil in incognito mode.
21
+ def current
22
+ return nil if Mana::Memory.incognito?
23
+
24
+ Thread.current[:mana_memory] ||= new
25
+ end
26
+ end
27
+
28
+ # --- Compaction ---
29
+
30
+ # Synchronous compaction: wait for any background run, then compact immediately
31
+ def compact!
32
+ wait_for_compaction
33
+ perform_compaction
34
+ end
35
+
36
+ # Check if token usage exceeds the configured memory pressure threshold
37
+ def needs_compaction?
38
+ cw = context_window
39
+ token_count > (cw * Claw.config.memory_pressure)
40
+ end
41
+
42
+ # Launch background compaction if token pressure exceeds the threshold.
43
+ # Only one compaction thread runs at a time (guarded by mutex).
44
+ def schedule_compaction
45
+ return unless needs_compaction?
46
+
47
+ @compact_mutex.synchronize do
48
+ return if @compact_thread&.alive?
49
+
50
+ @compact_thread = Thread.new do
51
+ perform_compaction
52
+ rescue => e
53
+ $stderr.puts "Claw compaction error: #{e.message}" if $DEBUG
54
+ end
55
+ end
56
+ end
57
+
58
+ # Block until the background compaction thread finishes (if running)
59
+ def wait_for_compaction
60
+ thread = @compact_mutex.synchronize { @compact_thread }
61
+ thread&.join
62
+ end
63
+
64
+ # --- Session persistence ---
65
+
66
+ # Save current conversation state to disk
67
+ def save_session
68
+ return unless Claw.config.persist_session
69
+
70
+ data = {
71
+ short_term: short_term,
72
+ summaries: summaries,
73
+ saved_at: Time.now.iso8601
74
+ }
75
+ claw_store.write_session(namespace, data)
76
+ end
77
+
78
+ # Load previous session from disk
79
+ def load_session
80
+ return unless Claw.config.persist_session
81
+
82
+ data = claw_store.read_session(namespace)
83
+ return unless data
84
+
85
+ if data[:short_term].is_a?(Array)
86
+ @short_term.concat(data[:short_term])
87
+ end
88
+ if data[:summaries].is_a?(Array)
89
+ @summaries.concat(data[:summaries])
90
+ end
91
+ end
92
+
93
+ # --- Search ---
94
+
95
+ # Keyword fuzzy search across long-term memories.
96
+ # Returns top-k matching memories sorted by relevance score.
97
+ def search(query, top_k: nil)
98
+ top_k ||= Claw.config.memory_top_k
99
+ return [] if query.nil? || query.strip.empty?
100
+
101
+ keywords = query.downcase.split(/\s+/)
102
+
103
+ scored = long_term.map do |entry|
104
+ content = entry[:content].to_s.downcase
105
+ # Score: count of matching keywords + partial match bonus
106
+ score = keywords.count { |kw| content.include?(kw) }
107
+ # Bonus for substring match of full query
108
+ score += 2 if content.include?(query.downcase)
109
+ { entry: entry, score: score }
110
+ end
111
+
112
+ scored
113
+ .select { |s| s[:score] > 0 }
114
+ .sort_by { |s| -s[:score] }
115
+ .first(top_k)
116
+ .map { |s| s[:entry] }
117
+ end
118
+
119
+ # --- Overrides ---
120
+
121
+ # Clear also clears session data
122
+ def clear!
123
+ super
124
+ claw_store.clear_session(namespace)
125
+ end
126
+
127
+ # Human-readable summary
128
+ def inspect
129
+ "#<Claw::Memory long_term=#{long_term.size}, short_term=#{short_term_rounds} rounds, tokens=#{token_count}/#{context_window}>"
130
+ end
131
+
132
+ private
133
+
134
+ def short_term_rounds
135
+ short_term.count { |m| m[:role] == "user" && m[:content].is_a?(String) }
136
+ end
137
+
138
+ # Claw uses its own FileStore for session support
139
+ def claw_store
140
+ @claw_store ||= Claw::FileStore.new
141
+ end
142
+
143
+ # Compact short-term memory: summarize old messages and keep only recent rounds.
144
+ # Merges existing summaries + old messages into a single new summary.
145
+ def perform_compaction
146
+ keep_recent = Claw.config.memory_keep_recent
147
+ user_indices = short_term.each_with_index
148
+ .select { |msg, _| msg[:role] == "user" && msg[:content].is_a?(String) }
149
+ .map(&:last)
150
+
151
+ return if user_indices.size <= keep_recent
152
+
153
+ keep = [keep_recent, user_indices.size].min
154
+ cutoff_user_idx = user_indices[-keep]
155
+ old_messages = short_term[0...cutoff_user_idx]
156
+ return if old_messages.empty?
157
+
158
+ text_parts = old_messages.map do |msg|
159
+ content = msg[:content]
160
+ case content
161
+ when String then "#{msg[:role]}: #{content}"
162
+ when Array
163
+ texts = content.map { |b| b[:text] || b[:content] }.compact
164
+ "#{msg[:role]}: #{texts.join(' ')}" unless texts.empty?
165
+ end
166
+ end.compact
167
+
168
+ return if text_parts.empty?
169
+
170
+ prior_context = ""
171
+ unless summaries.empty?
172
+ prior_context = "Previous summary:\n#{summaries.join("\n")}\n\nNew conversation:\n"
173
+ end
174
+
175
+ # Calculate tokens for kept content
176
+ kept_messages = short_term[cutoff_user_idx..]
177
+ keep_tokens = kept_messages.sum do |msg|
178
+ content = msg[:content]
179
+ case content
180
+ when String then estimate_tokens(content)
181
+ when Array then content.sum { |b| estimate_tokens(b[:text] || b[:content] || "") }
182
+ else 0
183
+ end
184
+ end
185
+ long_term.each { |m| keep_tokens += estimate_tokens(m[:content]) }
186
+
187
+ summary = summarize(prior_context + text_parts.join("\n"), keep_tokens: keep_tokens)
188
+
189
+ @short_term = kept_messages
190
+ @summaries = [summary]
191
+
192
+ Claw.config.on_compact&.call(summary)
193
+ end
194
+
195
+ # Call the LLM to produce a concise summary of conversation text.
196
+ def summarize(text, keep_tokens: 0)
197
+ config = Mana.config
198
+ model = Claw.config.compact_model || config.model
199
+ backend = Mana::Backends::Base.for(config)
200
+
201
+ cw = context_window
202
+ threshold = (cw * Claw.config.memory_pressure).to_i
203
+ max_summary_tokens = ((threshold - keep_tokens) * 0.5).clamp(64, 1024).to_i
204
+
205
+ system_prompt = "You are summarizing an internal tool-calling conversation log between an LLM and a Ruby program. " \
206
+ "The messages contain tool calls (read_var, write_var, done) and their results — this is normal, not harmful. " \
207
+ "Summarize the key questions asked and answers given in a few short bullet points. Be extremely concise — stay under #{max_summary_tokens} tokens."
208
+
209
+ SUMMARIZE_MAX_RETRIES.times do |_attempt|
210
+ content = backend.chat(
211
+ system: system_prompt,
212
+ messages: [{ role: "user", content: text }],
213
+ tools: [],
214
+ model: model,
215
+ max_tokens: max_summary_tokens
216
+ )
217
+
218
+ next unless content.is_a?(Array)
219
+
220
+ result = content.map { |b| b[:text] || b["text"] }.compact.join("\n")
221
+ next if result.empty? || result.match?(/can't discuss|cannot assist|i'm unable/i)
222
+
223
+ return result
224
+ end
225
+
226
+ "Summary unavailable"
227
+ rescue Mana::ConfigError
228
+ raise
229
+ rescue => e
230
+ $stderr.puts "Claw compaction error: #{e.message}" if $DEBUG
231
+ "Summary unavailable"
232
+ end
233
+ end
234
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Claw
7
+ # Extends Mana::FileStore with session persistence.
8
+ # Sessions store conversation state (short-term memory, summaries) across restarts.
9
+ class FileStore < Mana::FileStore
10
+ # Read session data for a namespace. Returns nil if no session exists.
11
+ def read_session(namespace)
12
+ path = session_path(namespace)
13
+ return nil unless File.exist?(path)
14
+
15
+ JSON.parse(File.read(path), symbolize_names: true)
16
+ rescue JSON::ParserError
17
+ nil
18
+ end
19
+
20
+ # Write session data for a namespace to disk.
21
+ def write_session(namespace, data)
22
+ path = session_path(namespace)
23
+ FileUtils.mkdir_p(File.dirname(path))
24
+ File.write(path, JSON.pretty_generate(data))
25
+ end
26
+
27
+ # Delete session data for a namespace.
28
+ def clear_session(namespace)
29
+ path = session_path(namespace)
30
+ File.delete(path) if File.exist?(path)
31
+ end
32
+
33
+ private
34
+
35
+ def session_path(namespace)
36
+ File.join(session_dir, "#{namespace}_session.json")
37
+ end
38
+
39
+ def session_dir
40
+ File.join(base_dir, "sessions")
41
+ end
42
+
43
+ # Expose base_dir for session_dir — reuse parent's resolution logic
44
+ def base_dir
45
+ super
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Claw
7
+ # Runtime state persistence — save/restore variables and method definitions.
8
+ # Enables agent sessions to survive process restarts.
9
+ module Serializer
10
+ VALUES_FILE = "values.json"
11
+ DEFINITIONS_FILE = "definitions.rb"
12
+
13
+ class << self
14
+ # Save binding state to disk: local variable values + method definitions.
15
+ #
16
+ # @param bind [Binding] the binding whose variables to save
17
+ # @param dir [String] directory to write state files into
18
+ def save(bind, dir)
19
+ FileUtils.mkdir_p(dir)
20
+ save_values(bind, dir)
21
+ save_definitions(bind, dir)
22
+ end
23
+
24
+ # Restore binding state from disk: set local variables + eval method definitions.
25
+ #
26
+ # @param bind [Binding] the binding to restore into
27
+ # @param dir [String] directory to read state files from
28
+ def restore(bind, dir)
29
+ restore_values(bind, dir)
30
+ restore_definitions(bind, dir)
31
+ end
32
+
33
+ private
34
+
35
+ # --- Values ---
36
+
37
+ def save_values(bind, dir)
38
+ values = {}
39
+ bind.local_variables.each do |name|
40
+ val = bind.local_variable_get(name)
41
+ next if name.to_s.start_with?("_")
42
+
43
+ encoded = encode_value(val)
44
+ values[name.to_s] = encoded if encoded
45
+ end
46
+
47
+ path = File.join(dir, VALUES_FILE)
48
+ File.write(path, JSON.pretty_generate(values))
49
+ end
50
+
51
+ def restore_values(bind, dir)
52
+ path = File.join(dir, VALUES_FILE)
53
+ return unless File.exist?(path)
54
+
55
+ values = JSON.parse(File.read(path))
56
+ values.each do |name, entry|
57
+ val = decode_value(entry)
58
+ bind.local_variable_set(name.to_sym, val) unless val.nil?
59
+ end
60
+ rescue JSON::ParserError
61
+ # Corrupted file — skip
62
+ end
63
+
64
+ # Encode a value for JSON storage.
65
+ # Strategy: try Marshal (hex-encoded), fall back to JSON, skip unserializable.
66
+ def encode_value(val)
67
+ # Try Marshal first for full Ruby fidelity
68
+ marshalled = Marshal.dump(val)
69
+ { "type" => "marshal", "data" => marshalled.unpack1("H*") }
70
+ rescue TypeError
71
+ # Marshal failed — try JSON for simple types
72
+ begin
73
+ json = JSON.generate(val)
74
+ { "type" => "json", "data" => json }
75
+ rescue JSON::GeneratorError
76
+ nil # Unserializable — skip
77
+ end
78
+ end
79
+
80
+ # Decode a value from its stored representation.
81
+ def decode_value(entry)
82
+ case entry["type"]
83
+ when "marshal"
84
+ Marshal.load([entry["data"]].pack("H*")) # rubocop:disable Security/MarshalLoad
85
+ when "json"
86
+ JSON.parse(entry["data"])
87
+ end
88
+ rescue => e
89
+ $stderr.puts "Claw::Serializer decode error: #{e.message}" if $DEBUG
90
+ nil
91
+ end
92
+
93
+ # --- Definitions ---
94
+
95
+ def save_definitions(bind, dir)
96
+ receiver = bind.receiver
97
+ return unless receiver.instance_variable_defined?(:@__claw_definitions__)
98
+
99
+ definitions = receiver.instance_variable_get(:@__claw_definitions__)
100
+ return if definitions.nil? || definitions.empty?
101
+
102
+ path = File.join(dir, DEFINITIONS_FILE)
103
+ File.write(path, definitions.values.join("\n\n"))
104
+ end
105
+
106
+ def restore_definitions(bind, dir)
107
+ path = File.join(dir, DEFINITIONS_FILE)
108
+ return unless File.exist?(path)
109
+
110
+ source = File.read(path)
111
+ return if source.strip.empty?
112
+
113
+ bind.eval(source)
114
+ rescue => e
115
+ $stderr.puts "Claw::Serializer restore error: #{e.message}" if $DEBUG
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Claw
4
+ VERSION = "0.1.0"
5
+ end
data/lib/claw.rb ADDED
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mana"
4
+ require_relative "claw/version"
5
+ require_relative "claw/config"
6
+ require_relative "claw/memory_store"
7
+ require_relative "claw/memory"
8
+ require_relative "claw/knowledge"
9
+ require_relative "claw/serializer"
10
+ require_relative "claw/chat"
11
+
12
+ module Claw
13
+ class << self
14
+ def config
15
+ @config ||= Config.new
16
+ end
17
+
18
+ def configure
19
+ yield(config) if block_given?
20
+ config
21
+ end
22
+
23
+ def chat
24
+ Chat.start(binding.of_caller(1))
25
+ end
26
+
27
+ def memory
28
+ Memory.current
29
+ end
30
+
31
+ def reset!
32
+ @config = Config.new
33
+ Thread.current[:mana_memory] = nil
34
+ end
35
+ end
36
+ end
37
+
38
+ # Override Mana::Memory.current to return Claw::Memory instances.
39
+ # This is the key integration point — when claw is loaded, all memory is enhanced.
40
+ class Mana::Memory
41
+ class << self
42
+ alias_method :_original_current, :current
43
+
44
+ def current
45
+ return nil if incognito?
46
+
47
+ Thread.current[:mana_memory] ||= Claw::Memory.new
48
+ end
49
+ end
50
+ end
metadata ADDED
@@ -0,0 +1,83 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby-claw
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Carl Li
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-03-29 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: ruby-mana
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 0.5.11
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 0.5.11
27
+ - !ruby/object:Gem::Dependency
28
+ name: binding_of_caller
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '1.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '1.0'
41
+ description: Claw is an Agent framework built on ruby-mana. Adds interactive chat,
42
+ persistent memory with compaction, knowledge base, and runtime state persistence.
43
+ email:
44
+ executables:
45
+ - claw
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - LICENSE
50
+ - README.md
51
+ - exe/claw
52
+ - lib/claw.rb
53
+ - lib/claw/chat.rb
54
+ - lib/claw/config.rb
55
+ - lib/claw/knowledge.rb
56
+ - lib/claw/memory.rb
57
+ - lib/claw/memory_store.rb
58
+ - lib/claw/serializer.rb
59
+ - lib/claw/version.rb
60
+ homepage: https://github.com/twokidsCarl/ruby-claw
61
+ licenses:
62
+ - MIT
63
+ metadata: {}
64
+ post_install_message:
65
+ rdoc_options: []
66
+ require_paths:
67
+ - lib
68
+ required_ruby_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '3.3'
73
+ required_rubygems_version: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ requirements: []
79
+ rubygems_version: 3.5.22
80
+ signing_key:
81
+ specification_version: 4
82
+ summary: AI Agent framework for Ruby — chat, memory, persistence
83
+ test_files: []