ruby-claw 0.1.1 → 0.1.2

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: 0e867d234a5c3796d4ac6d17161a1bdf78a14a34671d29fe47ebe55bd61ba678
4
- data.tar.gz: 34f158692c424ed0fccebe37a88f7bd7502a4f0fed0270ab90f3a6cabed44b8f
3
+ metadata.gz: 14400b4f156bbcd289918bf982f233f7c1055a246df1efac571b2e4852ef5752
4
+ data.tar.gz: 6f9cb9cee99ae272c0605c80f2256686ac256d7fa8c2cf4a3e19bdb330beee92
5
5
  SHA512:
6
- metadata.gz: 0c31c6b9b878c64691c986f550fa4d3a966e7666cf8a1e041c7b5a47b4ae2652fb7055496f78bd5461b5b073d1e27e9c09059d3641bbd360c15faa375056564c
7
- data.tar.gz: 804149d4ab957dedcaea0b89e3a7e28b31c5acc9a50ac209a81e4e29a69c5c35e870f2c5042a0ea35dcfee5f87d361ac8415e4d3f181d8aec152046e635834ec
6
+ metadata.gz: 6e7401c9af8cbe84fff2d677d34b4aab9d939e2e89e6355815a67fee4a5fcbf293c55f892bf144c096a78c8979d2d1d2eb52308a2ce4a2264f3d8456cb42a53b
7
+ data.tar.gz: 80ddb09282da9a9ec07ea6eb6a2ca4aa2282c61e1c3dd4338545873eb8fbb2a145955422600ea1eaa4829508818743adb2bd86be50ad1a6a8e70211b858919b8
data/CHANGELOG.md ADDED
@@ -0,0 +1,32 @@
1
+ # Changelog
2
+
3
+ ## [0.1.2] - 2026-04-04
4
+
5
+ ### Changed
6
+ - `Claw::Memory` is now fully independent (no longer inherits from `Mana::Memory`)
7
+ - Conversation context uses `context.messages` (renamed from `short_term`)
8
+ - Uses `Thread.current[:claw_memory]` instead of `:mana_memory`
9
+ - Remember tool registered via `Mana.register_tool` interface (no longer built into mana)
10
+ - Long-term memories injected into prompt via `Mana.register_prompt_section`
11
+
12
+ ### Added
13
+ - `Claw::Memory.incognito?` / `Claw::Memory.incognito(&block)` — claw-owned incognito mode
14
+ - `Claw.incognito(&block)` — convenience method
15
+ - `Thread.current[:claw_incognito]` for incognito state
16
+
17
+ ### Removed
18
+ - Dependency on `Mana::Context.incognito?` (incognito now self-contained in claw)
19
+
20
+ ## [0.1.1] - 2026-03-27
21
+
22
+ ### Added
23
+ - Markdown-based memory architecture (MEMORY.md, session.md, daily logs)
24
+ - Complete README with usage examples
25
+ - GitHub Pages website
26
+
27
+ ## [0.1.0] - 2026-03-27
28
+
29
+ ### Added
30
+ - Initial release: Chat REPL, persistent memory, compaction, session persistence
31
+ - Knowledge provider, runtime serializer
32
+ - Built on ruby-mana provider interfaces
data/README.md CHANGED
@@ -1,40 +1,95 @@
1
- # ruby-claw
1
+ # ruby-claw 🦀
2
2
 
3
- Agent framework for Ruby, built on [ruby-mana](https://github.com/twokidsCarl/ruby-mana).
3
+ [![Gem Version](https://badge.fury.io/rb/ruby-claw.svg)](https://rubygems.org/gems/ruby-claw) · [GitHub](https://github.com/twokidsCarl/ruby-claw)
4
4
 
5
- Claw extends mana's LLM engine with interactive chat, persistent memory with compaction, session persistence, and runtime state serialization.
5
+ AI Agent framework for Ruby. Built on [ruby-mana](https://github.com/twokidsCarl/ruby-mana).
6
6
 
7
- ## Install
7
+ ## What is Claw?
8
+
9
+ Claw turns ruby-mana's embedded LLM engine into a full agent with persistent memory, interactive chat, and session recovery. Think of it as the agent layer on top of mana's execution engine.
8
10
 
9
11
  ```ruby
10
- gem "ruby-claw"
12
+ gem install ruby-claw
11
13
  ```
12
14
 
13
- ## Usage
15
+ ## Features
14
16
 
17
+ ### Interactive Chat REPL
15
18
  ```ruby
16
19
  require "claw"
17
-
18
- # Start interactive chat
19
20
  Claw.chat
21
+ ```
22
+ Or from command line: `claw`
20
23
 
21
- # Access enhanced memory
22
- Claw.memory.search("ruby")
23
- Claw.memory.save_session
24
+ - Auto-detects Ruby code vs natural language
25
+ - Streaming output with markdown rendering
26
+ - `!` prefix forces Ruby eval
27
+ - Session persists across restarts
24
28
 
25
- # Configure
29
+ ### Persistent Memory
30
+ Claw stores memories as human-readable Markdown in `.mana/`:
31
+
32
+ ```
33
+ .mana/
34
+ MEMORY.md # Long-term facts (editable!)
35
+ session.md # Conversation summary
36
+ values.json # Variable snapshots
37
+ definitions.rb # Method definitions
38
+ log/
39
+ 2026-03-29.md # Daily interaction log
40
+ ```
41
+
42
+ The LLM can `remember` facts that persist across sessions:
43
+ ```ruby
44
+ claw> remember that the API uses OAuth2
45
+ claw> # ... next session ...
46
+ claw> what auth does our API use?
47
+ # => "OAuth2 — I remembered this from a previous session"
48
+ ```
49
+
50
+ ### Runtime Persistence
51
+ Variables and method definitions survive across sessions:
52
+ ```ruby
53
+ claw> a = 42
54
+ claw> def greet(name) = "Hello #{name}"
55
+ claw> exit
56
+
57
+ $ claw # restart
58
+ claw> a # => 42
59
+ claw> greet("world") # => "Hello world"
60
+ ```
61
+
62
+ ### Memory Compaction
63
+ When conversation grows large, old messages are automatically summarized in the background.
64
+
65
+ ### Keyword Memory Search
66
+ With many memories (>20), only the most relevant are injected into prompts.
67
+
68
+ ## Configuration
69
+
70
+ ```ruby
26
71
  Claw.configure do |c|
27
- c.memory_pressure = 0.7
28
- c.persist_session = true
72
+ c.memory_pressure = 0.7 # Compact when tokens > 70% of context window
73
+ c.memory_keep_recent = 4 # Keep last 4 conversation rounds during compaction
74
+ c.compact_model = nil # nil = use main model for summarization
75
+ c.persist_session = true # Save/restore session across restarts
76
+ c.memory_top_k = 10 # Max memories to inject when searching
77
+ c.on_compact = ->(summary) { puts summary }
78
+ end
79
+
80
+ # Mana config (inherited)
81
+ Mana.configure do |c|
82
+ c.model = "claude-sonnet-4-6"
83
+ c.api_key = "sk-..."
29
84
  end
30
85
  ```
31
86
 
32
- ## Components
87
+ ## Relationship with ruby-mana
88
+
89
+ - **ruby-mana** = Embedded LLM engine (`~"..."` syntax, binding manipulation, tool calling)
90
+ - **ruby-claw** = Agent framework (chat REPL, memory, persistence, knowledge)
33
91
 
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
92
+ Claw depends on mana. You can use mana standalone for embedding LLM in Ruby code, or add claw for interactive agent features.
38
93
 
39
94
  ## License
40
95
 
data/lib/claw/chat.rb CHANGED
@@ -26,6 +26,8 @@ module Claw
26
26
  def self.start(caller_binding)
27
27
  require "reline"
28
28
  load_history
29
+ load_compiled_methods(caller_binding)
30
+ restore_runtime(caller_binding)
29
31
  puts "#{DIM}Claw agent · type 'exit' to quit#{RESET}"
30
32
  puts
31
33
 
@@ -45,7 +47,8 @@ module Claw
45
47
  puts
46
48
  end
47
49
 
48
- # Save session on exit
50
+ # Save state on exit
51
+ save_runtime(caller_binding)
49
52
  Claw.memory&.save_session
50
53
  save_history
51
54
  puts "#{DIM}bye!#{RESET}"
@@ -62,6 +65,61 @@ module Claw
62
65
  end
63
66
  private_class_method :load_history
64
67
 
68
+ # Reload mana def compiled methods from .mana_cache/ on session start.
69
+ # These are pre-compiled Ruby methods that don't need LLM calls.
70
+ def self.load_compiled_methods(caller_binding)
71
+ cache_dir = Mana::Compiler.cache_dir
72
+ return unless Dir.exist?(cache_dir)
73
+
74
+ count = 0
75
+ Dir.glob(File.join(cache_dir, "*.rb")).each do |path|
76
+ code = File.read(path)
77
+ # Skip the header comment lines, eval the method definition
78
+ caller_binding.eval(code, path, 1)
79
+ count += 1
80
+ rescue => e
81
+ $stderr.puts "#{DIM} ⚠ could not load #{File.basename(path)}: #{e.message}#{RESET}" if Mana.config.verbose
82
+ end
83
+ puts "#{DIM} ✓ loaded #{count} compiled methods#{RESET}" if count > 0
84
+ rescue => e
85
+ # Don't crash on cache loading failure
86
+ end
87
+ private_class_method :load_compiled_methods
88
+
89
+ # Save Ruby runtime state (variables + method definitions) to .mana/
90
+ def self.save_runtime(caller_binding)
91
+ return unless Claw.config.persist_session
92
+ dir = File.join(Dir.pwd, ".mana")
93
+ Claw::Serializer.save(caller_binding, dir)
94
+ rescue => e
95
+ $stderr.puts "#{DIM} ⚠ could not save runtime: #{e.message}#{RESET}" if Mana.config.verbose
96
+ end
97
+ private_class_method :save_runtime
98
+
99
+ # Restore Ruby runtime state from .mana/
100
+ def self.restore_runtime(caller_binding)
101
+ return unless Claw.config.persist_session
102
+ dir = File.join(Dir.pwd, ".mana")
103
+ return unless File.exist?(File.join(dir, "values.json")) || File.exist?(File.join(dir, "definitions.rb"))
104
+
105
+ warnings = Claw::Serializer.restore(caller_binding, dir)
106
+ warnings.each { |w| puts "#{DIM} ⚠ #{w}#{RESET}" } if warnings.any?
107
+ puts "#{DIM} ✓ runtime restored#{RESET}"
108
+ rescue => e
109
+ puts "#{DIM} ⚠ could not restore runtime: #{e.message}#{RESET}"
110
+ end
111
+ private_class_method :restore_runtime
112
+
113
+ # Track method definitions for session persistence
114
+ def self.track_definition(caller_binding, code, method_name)
115
+ receiver = caller_binding.receiver
116
+ defs = receiver.instance_variable_defined?(:@__claw_definitions__) ?
117
+ receiver.instance_variable_get(:@__claw_definitions__) : {}
118
+ defs[method_name.to_s] = code
119
+ receiver.instance_variable_set(:@__claw_definitions__, defs)
120
+ end
121
+ private_class_method :track_definition
122
+
65
123
  def self.save_history
66
124
  lines = Reline::HISTORY.to_a.last(HISTORY_MAX)
67
125
  File.write(HISTORY_FILE, lines.join("\n") + "\n")
@@ -102,9 +160,16 @@ module Claw
102
160
 
103
161
  def self.eval_ruby(caller_binding, code)
104
162
  result = caller_binding.eval(code)
163
+ # Track method definitions: `def method_name` returns a Symbol in Ruby 3+
164
+ track_definition(caller_binding, code, result) if result.is_a?(Symbol) && code.strip.match?(/\Adef\s/)
105
165
  puts "#{RUBY_PREFIX}#{result.inspect}"
106
166
  rescue NameError, NoMethodError => e
107
- block_given? ? yield : puts("#{ERROR_COLOR}#{e.class}: #{e.message}#{RESET}")
167
+ # Fallback to LLM for natural language (multi-word or non-ASCII like Chinese)
168
+ if block_given? && (code.include?(" ") || code.match?(/[^\x00-\x7F]/))
169
+ yield
170
+ else
171
+ puts "#{ERROR_COLOR}#{e.class}: #{e.message}#{RESET}"
172
+ end
108
173
  rescue => e
109
174
  puts "#{ERROR_COLOR}#{e.class}: #{e.message}#{RESET}"
110
175
  end
@@ -164,6 +229,7 @@ module Claw
164
229
 
165
230
  # Schedule compaction after each exchange
166
231
  Claw.memory&.schedule_compaction
232
+ append_interaction_log(input, result)
167
233
  rescue Mana::LLMError, Mana::MaxIterationsError => e
168
234
  flush_line_buffer(line_buffer, in_code_block) if streaming_text
169
235
  puts "#{ERROR_COLOR}error: #{e.message}#{RESET}"
@@ -171,6 +237,18 @@ module Claw
171
237
  end
172
238
  private_class_method :run_claw
173
239
 
240
+ # --- Interaction logging ---
241
+
242
+ def self.append_interaction_log(input, result)
243
+ store = Claw::FileStore.new
244
+ title = input.length > 50 ? input[0..47] + "..." : input
245
+ detail = result ? "- Result: #{result.to_s[0..100]}" : "- (no result)"
246
+ store.append_log(title: title, detail: detail)
247
+ rescue => e
248
+ # Don't crash on log failure
249
+ end
250
+ private_class_method :append_interaction_log
251
+
174
252
  # --- Markdown rendering ---
175
253
 
176
254
  def self.render_line(line, in_code_block)
data/lib/claw/memory.rb CHANGED
@@ -1,30 +1,109 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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
4
+ # Standalone long-term memory with compaction, session persistence, and search.
5
+ # No longer inherits from Mana::Memory reads/writes Mana::Context for conversation state.
6
+ class Memory
7
7
  SUMMARIZE_MAX_RETRIES = 3
8
8
 
9
+ attr_reader :long_term
10
+
9
11
  def initialize
10
- super
12
+ @long_term = []
13
+ @next_id = 1
11
14
  @compact_mutex = Mutex.new
12
15
  @compact_thread = nil
16
+ load_long_term
13
17
  load_session if Claw.config.persist_session
14
18
  end
15
19
 
16
20
  # --- Class methods ---
17
21
 
18
22
  class << self
19
- # Return the current thread's memory instance (lazy-initialized).
23
+ # Check if the current thread is in claw incognito mode
24
+ def incognito?
25
+ Thread.current[:claw_incognito] == true
26
+ end
27
+
28
+ # Run a block with claw memory disabled. Long-term memory won't be
29
+ # loaded or saved, and the remember tool will be inactive.
30
+ def incognito(&block)
31
+ prev_incognito = Thread.current[:claw_incognito]
32
+ prev_memory = Thread.current[:claw_memory]
33
+ Thread.current[:claw_incognito] = true
34
+ Thread.current[:claw_memory] = nil
35
+ block.call
36
+ ensure
37
+ Thread.current[:claw_incognito] = prev_incognito
38
+ Thread.current[:claw_memory] = prev_memory
39
+ end
40
+
41
+ # Return the current thread's Claw memory instance (lazy-initialized).
42
+ # Uses a separate thread-local from Mana::Context.
20
43
  # Returns nil in incognito mode.
21
44
  def current
22
- return nil if Mana::Memory.incognito?
45
+ return nil if incognito?
23
46
 
24
- Thread.current[:mana_memory] ||= new
47
+ Thread.current[:claw_memory] ||= new
25
48
  end
26
49
  end
27
50
 
51
+ # --- Token estimation ---
52
+
53
+ # Rough token estimate: ~4 characters per token
54
+ def estimate_tokens(text)
55
+ return 0 unless text.is_a?(String)
56
+
57
+ (text.length / 4.0).ceil
58
+ end
59
+
60
+ # Estimate total token count across long-term memories + context messages/summaries
61
+ def token_count
62
+ count = 0
63
+ @long_term.each { |m| count += estimate_tokens(m[:content]) }
64
+ context = Mana::Context.current
65
+ if context
66
+ context.messages.each do |msg|
67
+ content = msg[:content]
68
+ case content
69
+ when String then count += estimate_tokens(content)
70
+ when Array
71
+ content.each { |block| count += estimate_tokens(block[:text] || block[:content] || "") }
72
+ end
73
+ end
74
+ context.summaries.each { |s| count += estimate_tokens(s) }
75
+ end
76
+ count
77
+ end
78
+
79
+ # --- Long-term memory management ---
80
+
81
+ # Store a fact in long-term memory. Deduplicates by content.
82
+ # Persists to disk immediately after adding.
83
+ def remember(content)
84
+ existing = @long_term.find { |e| e[:content] == content }
85
+ return existing if existing
86
+
87
+ entry = { id: @next_id, content: content, created_at: Time.now.iso8601 }
88
+ @next_id += 1
89
+ @long_term << entry
90
+ store.write(namespace, @long_term)
91
+ claw_store.append_log(title: "Remembered", detail: "- #{content}")
92
+ entry
93
+ end
94
+
95
+ # Remove a specific long-term memory by ID and persist the change
96
+ def forget(id:)
97
+ @long_term.reject! { |m| m[:id] == id }
98
+ store.write(namespace, @long_term)
99
+ end
100
+
101
+ # Clear persistent memories from both in-memory array and disk
102
+ def clear_long_term!
103
+ @long_term.clear
104
+ store.clear(namespace)
105
+ end
106
+
28
107
  # --- Compaction ---
29
108
 
30
109
  # Synchronous compaction: wait for any background run, then compact immediately
@@ -67,9 +146,10 @@ module Claw
67
146
  def save_session
68
147
  return unless Claw.config.persist_session
69
148
 
149
+ context = Mana::Context.current
70
150
  data = {
71
- short_term: short_term,
72
- summaries: summaries,
151
+ short_term: context&.messages || [],
152
+ summaries: context&.summaries || [],
73
153
  saved_at: Time.now.iso8601
74
154
  }
75
155
  claw_store.write_session(namespace, data)
@@ -82,11 +162,12 @@ module Claw
82
162
  data = claw_store.read_session(namespace)
83
163
  return unless data
84
164
 
85
- if data[:short_term].is_a?(Array)
86
- @short_term.concat(data[:short_term])
165
+ context = Mana::Context.current
166
+ if data[:short_term].is_a?(Array) && context
167
+ context.messages.concat(data[:short_term])
87
168
  end
88
- if data[:summaries].is_a?(Array)
89
- @summaries.concat(data[:summaries])
169
+ if data[:summaries].is_a?(Array) && context
170
+ context.summaries.concat(data[:summaries])
90
171
  end
91
172
  end
92
173
 
@@ -102,9 +183,7 @@ module Claw
102
183
 
103
184
  scored = long_term.map do |entry|
104
185
  content = entry[:content].to_s.downcase
105
- # Score: count of matching keywords + partial match bonus
106
186
  score = keywords.count { |kw| content.include?(kw) }
107
- # Bonus for substring match of full query
108
187
  score += 2 if content.include?(query.downcase)
109
188
  { entry: entry, score: score }
110
189
  end
@@ -116,15 +195,18 @@ module Claw
116
195
  .map { |s| s[:entry] }
117
196
  end
118
197
 
119
- # --- Overrides ---
198
+ # --- Clearing ---
120
199
 
121
- # Clear also clears session data
200
+ # Clear all memory (long-term + context + session)
122
201
  def clear!
123
- super
202
+ @long_term.clear
203
+ store.clear(namespace)
124
204
  claw_store.clear_session(namespace)
205
+ Mana::Context.current&.clear!
125
206
  end
126
207
 
127
- # Human-readable summary
208
+ # --- Display ---
209
+
128
210
  def inspect
129
211
  "#<Claw::Memory long_term=#{long_term.size}, short_term=#{short_term_rounds} rounds, tokens=#{token_count}/#{context_window}>"
130
212
  end
@@ -132,19 +214,64 @@ module Claw
132
214
  private
133
215
 
134
216
  def short_term_rounds
135
- short_term.count { |m| m[:role] == "user" && m[:content].is_a?(String) }
217
+ context = Mana::Context.current
218
+ return 0 unless context
219
+ context.messages.count { |m| m[:role] == "user" && m[:content].is_a?(String) }
220
+ end
221
+
222
+ def context_window
223
+ Mana.config.context_window
136
224
  end
137
225
 
138
- # Claw uses its own FileStore for session support
226
+ # Claw uses its own FileStore for session/log support
139
227
  def claw_store
140
228
  @claw_store ||= Claw::FileStore.new
141
229
  end
142
230
 
231
+ # Resolve memory store: user config > default file-based store
232
+ def store
233
+ Mana.config.memory_store || default_store
234
+ end
235
+
236
+ # Lazy-initialized default FileStore singleton
237
+ def default_store
238
+ @default_store ||= Mana::FileStore.new
239
+ end
240
+
241
+ def namespace
242
+ ns = Mana.config.namespace
243
+ return ns if ns && !ns.to_s.empty?
244
+
245
+ dir = `git rev-parse --show-toplevel 2>/dev/null`.strip
246
+ return File.basename(dir) unless dir.empty?
247
+
248
+ d = Dir.pwd
249
+ loop do
250
+ return File.basename(d) if File.exist?(File.join(d, "Gemfile"))
251
+ parent = File.dirname(d)
252
+ break if parent == d
253
+ d = parent
254
+ end
255
+
256
+ File.basename(Dir.pwd)
257
+ end
258
+
259
+ # Load long-term memories from the persistent store on initialization.
260
+ def load_long_term
261
+ return if self.class.incognito?
262
+
263
+ @long_term = store.read(namespace)
264
+ @next_id = (@long_term.map { |m| m[:id] }.max || 0) + 1
265
+ end
266
+
143
267
  # Compact short-term memory: summarize old messages and keep only recent rounds.
144
- # Merges existing summaries + old messages into a single new summary.
145
268
  def perform_compaction
269
+ context = Mana::Context.current
270
+ return unless context
271
+
146
272
  keep_recent = Claw.config.memory_keep_recent
147
- user_indices = short_term.each_with_index
273
+ messages = context.messages
274
+ user_indices = messages.each_with_index
148
275
  .select { |msg, _| msg[:role] == "user" && msg[:content].is_a?(String) }
149
276
  .map(&:last)
150
277
 
@@ -152,7 +279,7 @@ module Claw
152
279
 
153
280
  keep = [keep_recent, user_indices.size].min
154
281
  cutoff_user_idx = user_indices[-keep]
155
- old_messages = short_term[0...cutoff_user_idx]
282
+ old_messages = messages[0...cutoff_user_idx]
156
283
  return if old_messages.empty?
157
284
 
158
285
  text_parts = old_messages.map do |msg|
@@ -168,12 +295,12 @@ module Claw
168
295
  return if text_parts.empty?
169
296
 
170
297
  prior_context = ""
171
- unless summaries.empty?
172
- prior_context = "Previous summary:\n#{summaries.join("\n")}\n\nNew conversation:\n"
298
+ unless context.summaries.empty?
299
+ prior_context = "Previous summary:\n#{context.summaries.join("\n")}\n\nNew conversation:\n"
173
300
  end
174
301
 
175
302
  # Calculate tokens for kept content
176
- kept_messages = short_term[cutoff_user_idx..]
303
+ kept_messages = messages[cutoff_user_idx..]
177
304
  keep_tokens = kept_messages.sum do |msg|
178
305
  content = msg[:content]
179
306
  case content
@@ -186,10 +313,11 @@ module Claw
186
313
 
187
314
  summary = summarize(prior_context + text_parts.join("\n"), keep_tokens: keep_tokens)
188
315
 
189
- @short_term = kept_messages
190
- @summaries = [summary]
316
+ context.messages.replace(kept_messages)
317
+ context.summaries.replace([summary])
191
318
 
192
319
  Claw.config.on_compact&.call(summary)
320
+ claw_store.append_log(title: "Memory compacted", detail: "- Summaries updated")
193
321
  end
194
322
 
195
323
  # Call the LLM to produce a concise summary of conversation text.
@@ -1,48 +1,178 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
3
  require "fileutils"
5
4
 
6
5
  module Claw
7
- # Extends Mana::FileStore with session persistence.
8
- # Sessions store conversation state (short-term memory, summaries) across restarts.
6
+ # Extends Mana::FileStore with Markdown-based persistence.
7
+ # Replaces JSON memory/session files with human-readable Markdown.
8
+ #
9
+ # Directory layout:
10
+ # .mana/
11
+ # MEMORY.md — long-term memory
12
+ # session.md — session summary
13
+ # values.json — kept as-is (Marshal data)
14
+ # definitions.rb — kept as-is
15
+ # log/
16
+ # YYYY-MM-DD.md — daily interaction log
9
17
  class FileStore < Mana::FileStore
10
- # Read session data for a namespace. Returns nil if no session exists.
18
+ # Read long-term memories from MEMORY.md.
19
+ # Returns [{id:, content:, created_at:}, ...]
20
+ def read(namespace)
21
+ path = memory_md_path
22
+ return [] unless File.exist?(path)
23
+
24
+ parse_memory_md(File.read(path))
25
+ end
26
+
27
+ # Write long-term memories to MEMORY.md.
28
+ def write(namespace, memories)
29
+ FileUtils.mkdir_p(base_dir)
30
+ File.write(memory_md_path, generate_memory_md(memories))
31
+ end
32
+
33
+ # Delete MEMORY.md.
34
+ def clear(namespace)
35
+ File.delete(memory_md_path) if File.exist?(memory_md_path)
36
+ end
37
+
38
+ # Read session data from session.md. Returns {summaries: [...]} or nil.
11
39
  def read_session(namespace)
12
- path = session_path(namespace)
40
+ path = session_md_path
13
41
  return nil unless File.exist?(path)
14
42
 
15
- JSON.parse(File.read(path), symbolize_names: true)
16
- rescue JSON::ParserError
17
- nil
43
+ parse_session_md(File.read(path))
18
44
  end
19
45
 
20
- # Write session data for a namespace to disk.
46
+ # Write session data to session.md.
21
47
  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))
48
+ FileUtils.mkdir_p(base_dir)
49
+ File.write(session_md_path, generate_session_md(data))
25
50
  end
26
51
 
27
- # Delete session data for a namespace.
52
+ # Delete session.md.
28
53
  def clear_session(namespace)
29
- path = session_path(namespace)
30
- File.delete(path) if File.exist?(path)
54
+ File.delete(session_md_path) if File.exist?(session_md_path)
55
+ end
56
+
57
+ # Append an entry to the daily log file (log/YYYY-MM-DD.md).
58
+ def append_log(entry)
59
+ dir = File.join(base_dir, "log")
60
+ FileUtils.mkdir_p(dir)
61
+ date = Time.now.strftime("%Y-%m-%d")
62
+ path = File.join(dir, "#{date}.md")
63
+
64
+ unless File.exist?(path)
65
+ File.write(path, "# #{date}\n\n")
66
+ end
67
+
68
+ time = Time.now.strftime("%H:%M")
69
+ File.open(path, "a") { |f| f.puts "## #{time} — #{entry[:title]}\n#{entry[:detail]}\n" }
31
70
  end
32
71
 
33
72
  private
34
73
 
35
- def session_path(namespace)
36
- File.join(session_dir, "#{namespace}_session.json")
74
+ def base_dir
75
+ @base_path || Mana.config.memory_path || File.join(Dir.pwd, ".mana")
37
76
  end
38
77
 
39
- def session_dir
40
- File.join(base_dir, "sessions")
78
+ def memory_md_path
79
+ File.join(base_dir, "MEMORY.md")
41
80
  end
42
81
 
43
- # Expose base_dir for session_dir — reuse parent's resolution logic
44
- def base_dir
45
- super
82
+ def session_md_path
83
+ File.join(base_dir, "session.md")
84
+ end
85
+
86
+ # Parse MEMORY.md: split on ## id:N | date headers
87
+ def parse_memory_md(text)
88
+ memories = []
89
+ current = nil
90
+ text.each_line do |line|
91
+ if line.match?(/^## id:(\d+) \| (.+)/)
92
+ if current
93
+ current[:content] = current[:content].strip
94
+ memories << current
95
+ end
96
+ md = line.match(/^## id:(\d+) \| (.+)/)
97
+ current = { id: md[1].to_i, content: "", created_at: md[2].strip }
98
+ elsif current
99
+ current[:content] += line
100
+ end
101
+ end
102
+ if current
103
+ current[:content] = current[:content].strip
104
+ memories << current
105
+ end
106
+ memories
107
+ end
108
+
109
+ # Generate MEMORY.md from array of memories
110
+ def generate_memory_md(memories)
111
+ lines = ["# Long-term Memory\n"]
112
+ memories.each do |m|
113
+ date = m[:created_at] || Time.now.iso8601
114
+ lines << "## id:#{m[:id]} | #{date}"
115
+ lines << m[:content].to_s
116
+ lines << ""
117
+ end
118
+ lines.join("\n")
119
+ end
120
+
121
+ # Parse session.md: extract summaries and short_term from sections
122
+ def parse_session_md(text)
123
+ summaries = []
124
+ short_term = []
125
+ in_summary = false
126
+ in_short_term = false
127
+ text.each_line do |line|
128
+ if line.strip == "## Summary"
129
+ in_summary = true
130
+ in_short_term = false
131
+ next
132
+ elsif line.strip == "## Short-term"
133
+ in_short_term = true
134
+ in_summary = false
135
+ next
136
+ elsif line.start_with?("## ")
137
+ in_summary = false
138
+ in_short_term = false
139
+ elsif in_summary && line.strip.start_with?("- ")
140
+ summaries << line.strip.sub(/^- /, "")
141
+ elsif in_short_term && line.strip.start_with?("- ")
142
+ # Format: "- role: content"
143
+ stripped = line.strip.sub(/^- /, "")
144
+ if (md = stripped.match(/\A(\w+): (.*)\z/m))
145
+ short_term << { role: md[1], content: md[2] }
146
+ end
147
+ end
148
+ end
149
+ { summaries: summaries, short_term: short_term }
150
+ end
151
+
152
+ # Generate session.md from session data hash
153
+ def generate_session_md(data)
154
+ return "" unless data
155
+ lines = ["# Session State\n"]
156
+ summaries = data[:summaries] || data["summaries"] || []
157
+ unless summaries.empty?
158
+ lines << "## Summary"
159
+ summaries.each { |s| lines << "- #{s}" }
160
+ lines << ""
161
+ end
162
+ short_term = data[:short_term] || data["short_term"] || []
163
+ unless short_term.empty?
164
+ lines << "## Short-term"
165
+ short_term.each do |msg|
166
+ role = msg[:role] || msg["role"]
167
+ content = msg[:content] || msg["content"]
168
+ lines << "- #{role}: #{content}" if content.is_a?(String)
169
+ end
170
+ lines << ""
171
+ end
172
+ lines << "## Last Updated"
173
+ lines << (data[:updated_at] || data["updated_at"] || data[:saved_at] || data["saved_at"] || Time.now.iso8601)
174
+ lines << ""
175
+ lines.join("\n")
46
176
  end
47
177
  end
48
178
  end
data/lib/claw/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Claw
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.2"
5
5
  end
data/lib/claw.rb CHANGED
@@ -28,23 +28,50 @@ module Claw
28
28
  Memory.current
29
29
  end
30
30
 
31
+ def incognito(&block)
32
+ Memory.incognito(&block)
33
+ end
34
+
31
35
  def reset!
32
36
  @config = Config.new
33
- Thread.current[:mana_memory] = nil
37
+ Thread.current[:claw_memory] = nil
38
+ Thread.current[:mana_context] = nil
34
39
  end
35
40
  end
36
41
  end
37
42
 
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
+ # Register Claw's remember tool via Mana's tool registration interface.
44
+ Mana.register_tool(
45
+ {
46
+ name: "remember",
47
+ description: "Store a fact in long-term memory. This memory persists across script executions. Use when the user explicitly asks to remember something.",
48
+ input_schema: {
49
+ type: "object",
50
+ properties: { content: { type: "string", description: "The fact to remember" } },
51
+ required: ["content"]
52
+ }
53
+ }
54
+ ) do |input|
55
+ memory = Claw.memory # nil when incognito
56
+ if memory
57
+ entry = memory.remember(input["content"])
58
+ "Remembered (id=#{entry[:id]}): #{input['content']}"
59
+ else
60
+ "Memory not available"
61
+ end
62
+ end
43
63
 
44
- def current
45
- return nil if incognito?
64
+ # Register prompt section to inject long-term memories into system prompt.
65
+ Mana.register_prompt_section do
66
+ memory = Claw.memory
67
+ next nil unless memory && !memory.long_term.empty?
46
68
 
47
- Thread.current[:mana_memory] ||= Claw::Memory.new
48
- end
49
- end
69
+ lines = ["Long-term memories (persistent background context):"]
70
+ memory.long_term.each { |m| lines << "- #{m[:content]}" }
71
+ lines << ""
72
+ lines << "You have a `remember` tool to store new facts in long-term memory when the user asks."
73
+ lines.join("\n")
50
74
  end
75
+
76
+ # Register Claw's enhanced knowledge provider.
77
+ Mana.config.knowledge_provider = Claw::Knowledge
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-claw
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carl Li
8
8
  autorequire:
9
9
  bindir: exe
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: ruby-mana
@@ -60,6 +60,7 @@ executables:
60
60
  extensions: []
61
61
  extra_rdoc_files: []
62
62
  files:
63
+ - CHANGELOG.md
63
64
  - LICENSE
64
65
  - README.md
65
66
  - exe/claw