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 +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +74 -19
- data/lib/claw/chat.rb +80 -2
- data/lib/claw/memory.rb +157 -29
- data/lib/claw/memory_store.rb +152 -22
- data/lib/claw/version.rb +1 -1
- data/lib/claw.rb +38 -11
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 14400b4f156bbcd289918bf982f233f7c1055a246df1efac571b2e4852ef5752
|
|
4
|
+
data.tar.gz: 6f9cb9cee99ae272c0605c80f2256686ac256d7fa8c2cf4a3e19bdb330beee92
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
3
|
+
[](https://rubygems.org/gems/ruby-claw) · [GitHub](https://github.com/twokidsCarl/ruby-claw)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
AI Agent framework for Ruby. Built on [ruby-mana](https://github.com/twokidsCarl/ruby-mana).
|
|
6
6
|
|
|
7
|
-
##
|
|
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
|
|
12
|
+
gem install ruby-claw
|
|
11
13
|
```
|
|
12
14
|
|
|
13
|
-
##
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
5
|
-
#
|
|
6
|
-
class 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
|
-
|
|
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
|
-
#
|
|
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
|
|
45
|
+
return nil if incognito?
|
|
23
46
|
|
|
24
|
-
Thread.current[:
|
|
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:
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
# ---
|
|
198
|
+
# --- Clearing ---
|
|
120
199
|
|
|
121
|
-
# Clear
|
|
200
|
+
# Clear all memory (long-term + context + session)
|
|
122
201
|
def clear!
|
|
123
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
190
|
-
|
|
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.
|
data/lib/claw/memory_store.rb
CHANGED
|
@@ -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
|
|
8
|
-
#
|
|
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
|
|
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 =
|
|
40
|
+
path = session_md_path
|
|
13
41
|
return nil unless File.exist?(path)
|
|
14
42
|
|
|
15
|
-
|
|
16
|
-
rescue JSON::ParserError
|
|
17
|
-
nil
|
|
43
|
+
parse_session_md(File.read(path))
|
|
18
44
|
end
|
|
19
45
|
|
|
20
|
-
# Write session data
|
|
46
|
+
# Write session data to session.md.
|
|
21
47
|
def write_session(namespace, data)
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
52
|
+
# Delete session.md.
|
|
28
53
|
def clear_session(namespace)
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
36
|
-
File.join(
|
|
74
|
+
def base_dir
|
|
75
|
+
@base_path || Mana.config.memory_path || File.join(Dir.pwd, ".mana")
|
|
37
76
|
end
|
|
38
77
|
|
|
39
|
-
def
|
|
40
|
-
File.join(base_dir, "
|
|
78
|
+
def memory_md_path
|
|
79
|
+
File.join(base_dir, "MEMORY.md")
|
|
41
80
|
end
|
|
42
81
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
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[:
|
|
37
|
+
Thread.current[:claw_memory] = nil
|
|
38
|
+
Thread.current[:mana_context] = nil
|
|
34
39
|
end
|
|
35
40
|
end
|
|
36
41
|
end
|
|
37
42
|
|
|
38
|
-
#
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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.
|
|
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-
|
|
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
|