rcrewai 0.5.0 → 0.6.1
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 -1
- data/README.md +30 -4
- data/ROADMAP.md +1 -2
- data/docs/superpowers/specs/2026-07-06-cognitive-memory-design.md +118 -0
- data/docs/upgrading-to-0.6.md +101 -0
- data/examples/cognitive_memory_example.rb +66 -0
- data/lib/rcrewai/agent.rb +13 -1
- data/lib/rcrewai/knowledge/embedder.rb +78 -14
- data/lib/rcrewai/knowledge/store.rb +4 -14
- data/lib/rcrewai/memory/base_memory.rb +107 -0
- data/lib/rcrewai/memory/entity_memory.rb +62 -0
- data/lib/rcrewai/memory/in_memory_store.rb +40 -0
- data/lib/rcrewai/memory/llm_entity_extractor.rb +34 -0
- data/lib/rcrewai/memory/long_term_memory.rb +39 -0
- data/lib/rcrewai/memory/short_term_memory.rb +20 -0
- data/lib/rcrewai/memory/sqlite_store.rb +114 -0
- data/lib/rcrewai/memory/tool_memory.rb +38 -0
- data/lib/rcrewai/memory.rb +57 -160
- data/lib/rcrewai/similarity.rb +43 -0
- data/lib/rcrewai/version.rb +1 -1
- data/lib/rcrewai.rb +9 -0
- data/rcrewai.gemspec +1 -0
- metadata +27 -1
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require_relative '../similarity'
|
|
5
|
+
require_relative 'in_memory_store'
|
|
6
|
+
|
|
7
|
+
module RCrewAI
|
|
8
|
+
class Memory
|
|
9
|
+
# Shared behavior for the memory types: embed-on-write (when an embedder is
|
|
10
|
+
# present) and semantic recall with a lexical fallback when it isn't.
|
|
11
|
+
# Records are namespaced per (agent) scope + a type suffix so different
|
|
12
|
+
# memory types don't collide in a shared store.
|
|
13
|
+
class BaseMemory
|
|
14
|
+
def initialize(scope:, embedder: nil, store: nil, limit: nil)
|
|
15
|
+
@scope = "#{scope}:#{type_suffix}"
|
|
16
|
+
@embedder = embedder
|
|
17
|
+
@store = store || InMemoryStore.new
|
|
18
|
+
@limit = limit
|
|
19
|
+
@seq = 0
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def record(text, metadata = {})
|
|
23
|
+
add(text, metadata)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def recall(query, limit: 3)
|
|
27
|
+
records = search_records(query, limit)
|
|
28
|
+
records.map { |r| format(r) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def count
|
|
32
|
+
@store.all(scope: @scope).length
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def clear!
|
|
36
|
+
@store.delete(scope: @scope)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
protected
|
|
40
|
+
|
|
41
|
+
# Subclasses override to namespace their records.
|
|
42
|
+
def type_suffix
|
|
43
|
+
'base'
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def format(record)
|
|
47
|
+
{ text: record[:text], metadata: record[:metadata] }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def add(text, metadata)
|
|
51
|
+
vector = embed(text)
|
|
52
|
+
id = next_id(text)
|
|
53
|
+
@store.add(id: id, text: text, vector: vector, scope: @scope, metadata: stringify(metadata))
|
|
54
|
+
evict_if_needed
|
|
55
|
+
id
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def search_records(query, limit)
|
|
59
|
+
all = @store.all(scope: @scope)
|
|
60
|
+
return [] if all.empty?
|
|
61
|
+
|
|
62
|
+
query_vector = embed(query)
|
|
63
|
+
if query_vector && all.any? { |r| r[:vector] }
|
|
64
|
+
@store.search(query_vector, k: limit, scope: @scope)
|
|
65
|
+
else
|
|
66
|
+
lexical_search(query, all, limit)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def lexical_search(query, records, limit)
|
|
71
|
+
records
|
|
72
|
+
.map { |r| [r, Similarity.lexical(query, r[:text])] }
|
|
73
|
+
.sort_by { |(_r, score)| -score }
|
|
74
|
+
.first(limit)
|
|
75
|
+
.map(&:first)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def embed(text)
|
|
79
|
+
return nil unless @embedder
|
|
80
|
+
|
|
81
|
+
@embedder.embed([text]).first
|
|
82
|
+
rescue StandardError
|
|
83
|
+
nil # embedding is best-effort; fall back to lexical
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def evict_if_needed
|
|
87
|
+
return unless @limit
|
|
88
|
+
|
|
89
|
+
records = @store.all(scope: @scope)
|
|
90
|
+
return if records.length <= @limit
|
|
91
|
+
|
|
92
|
+
# records carry a monotonic :seq in metadata; drop the oldest.
|
|
93
|
+
oldest = records.min_by { |r| r[:metadata]['seq'].to_i }
|
|
94
|
+
@store.delete_record(id: oldest[:id], scope: @scope) if @store.respond_to?(:delete_record)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def next_id(text)
|
|
98
|
+
@seq += 1
|
|
99
|
+
Digest::SHA256.hexdigest("#{@scope}:#{@seq}:#{text}")[0, 24]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def stringify(metadata)
|
|
103
|
+
(metadata || {}).transform_keys(&:to_s).merge('seq' => @seq)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base_memory'
|
|
4
|
+
|
|
5
|
+
module RCrewAI
|
|
6
|
+
class Memory
|
|
7
|
+
# Facts about entities (people, systems, concepts) accumulated from work.
|
|
8
|
+
# Entities are extracted heuristically (capitalized tokens / acronyms); an
|
|
9
|
+
# LLM-backed extractor can be swapped in later.
|
|
10
|
+
class EntityMemory < BaseMemory
|
|
11
|
+
# Skip sentence-initial common words that happen to be capitalized.
|
|
12
|
+
COMMON = %w[The A An I In On At To For Of With By And Or But It This That Who Where When].freeze
|
|
13
|
+
|
|
14
|
+
def initialize(scope:, embedder: nil, store: nil, limit: nil, extractor: nil)
|
|
15
|
+
super(scope: scope, embedder: embedder, store: store, limit: limit)
|
|
16
|
+
@extractor = extractor
|
|
17
|
+
@entities = []
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Records a full observation and indexes the entities it mentions.
|
|
21
|
+
def observe(text)
|
|
22
|
+
found = extract_entities(text)
|
|
23
|
+
@entities.concat(found)
|
|
24
|
+
add(text, { 'entities' => found })
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def entities
|
|
28
|
+
@entities.uniq
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
protected
|
|
32
|
+
|
|
33
|
+
def type_suffix
|
|
34
|
+
'entity'
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def format(record)
|
|
38
|
+
record[:text]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# Uses the custom extractor when provided, falling back to the heuristic
|
|
44
|
+
# if it's absent, returns nothing, or raises.
|
|
45
|
+
def extract_entities(text)
|
|
46
|
+
if @extractor
|
|
47
|
+
begin
|
|
48
|
+
result = Array(@extractor.call(text))
|
|
49
|
+
return result unless result.empty?
|
|
50
|
+
rescue StandardError
|
|
51
|
+
# fall through to heuristic
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
heuristic_entities(text)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def heuristic_entities(text)
|
|
58
|
+
text.to_s.scan(/\b([A-Z][a-zA-Z0-9]{1,})\b/).flatten.reject { |w| COMMON.include?(w) }.uniq
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../similarity'
|
|
4
|
+
|
|
5
|
+
module RCrewAI
|
|
6
|
+
class Memory
|
|
7
|
+
# Default, volatile vector store: records live in a Hash keyed by scope.
|
|
8
|
+
# Records: { id:, text:, vector:, metadata: }. Cosine search in Ruby.
|
|
9
|
+
class InMemoryStore
|
|
10
|
+
def initialize
|
|
11
|
+
@scopes = Hash.new { |h, k| h[k] = {} }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def add(id:, text:, vector:, scope:, metadata: {})
|
|
15
|
+
@scopes[scope][id] = { id: id, text: text, vector: vector, metadata: metadata || {} }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def all(scope:)
|
|
19
|
+
@scopes[scope].values
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def search(vector, k:, scope:)
|
|
23
|
+
@scopes[scope].values
|
|
24
|
+
.reject { |r| r[:vector].nil? }
|
|
25
|
+
.map { |r| [r, Similarity.cosine(vector, r[:vector])] }
|
|
26
|
+
.sort_by { |(_r, score)| -score }
|
|
27
|
+
.first(k)
|
|
28
|
+
.map(&:first)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def delete(scope:)
|
|
32
|
+
@scopes.delete(scope)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def delete_record(id:, scope:)
|
|
36
|
+
@scopes[scope].delete(id)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../output_schema'
|
|
4
|
+
|
|
5
|
+
module RCrewAI
|
|
6
|
+
class Memory
|
|
7
|
+
# Extracts named entities from text with an LLM. Pass an instance as the
|
|
8
|
+
# `extractor:` for EntityMemory. Responds to `call(text) -> [names]`.
|
|
9
|
+
# Returns [] on any parse/LLM failure so EntityMemory falls back to the
|
|
10
|
+
# heuristic extractor — extraction is best-effort and never fatal.
|
|
11
|
+
class LlmEntityExtractor
|
|
12
|
+
PROMPT = <<~PROMPT
|
|
13
|
+
Extract the named entities (people, organizations, systems, products,
|
|
14
|
+
places, and key concepts) from the text below. Respond ONLY with a JSON
|
|
15
|
+
array of strings, e.g. ["Alice", "Payments", "Redis"]. No prose.
|
|
16
|
+
|
|
17
|
+
Text:
|
|
18
|
+
PROMPT
|
|
19
|
+
|
|
20
|
+
def initialize(llm)
|
|
21
|
+
@llm = llm
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call(text)
|
|
25
|
+
response = @llm.chat(messages: [{ role: 'user', content: "#{PROMPT}#{text}" }])
|
|
26
|
+
content = response.is_a?(Hash) ? response[:content] : response
|
|
27
|
+
parsed = OutputSchema.parse(content.to_s)
|
|
28
|
+
parsed.is_a?(Array) ? parsed.map(&:to_s) : []
|
|
29
|
+
rescue StandardError
|
|
30
|
+
[]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base_memory'
|
|
4
|
+
|
|
5
|
+
module RCrewAI
|
|
6
|
+
class Memory
|
|
7
|
+
# Durable insights promoted from successful executions. Dedupes
|
|
8
|
+
# near-identical insights so the store doesn't fill with paraphrases.
|
|
9
|
+
class LongTermMemory < BaseMemory
|
|
10
|
+
DEDUPE_THRESHOLD = 0.92
|
|
11
|
+
|
|
12
|
+
def record(text, metadata = {})
|
|
13
|
+
return nil if duplicate?(text)
|
|
14
|
+
|
|
15
|
+
add(text, metadata)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
protected
|
|
19
|
+
|
|
20
|
+
def type_suffix
|
|
21
|
+
'long_term'
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def duplicate?(text)
|
|
27
|
+
existing = @store.all(scope: @scope)
|
|
28
|
+
return false if existing.empty?
|
|
29
|
+
|
|
30
|
+
query_vector = embed(text)
|
|
31
|
+
if query_vector && existing.any? { |r| r[:vector] }
|
|
32
|
+
existing.any? { |r| r[:vector] && Similarity.cosine(query_vector, r[:vector]) >= DEDUPE_THRESHOLD }
|
|
33
|
+
else
|
|
34
|
+
existing.any? { |r| Similarity.lexical(text, r[:text]) >= DEDUPE_THRESHOLD }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base_memory'
|
|
4
|
+
|
|
5
|
+
module RCrewAI
|
|
6
|
+
class Memory
|
|
7
|
+
# Recent executions with semantic recall. Capped and volatile-by-default.
|
|
8
|
+
class ShortTermMemory < BaseMemory
|
|
9
|
+
def initialize(scope:, embedder: nil, store: nil, limit: 100)
|
|
10
|
+
super
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
protected
|
|
14
|
+
|
|
15
|
+
def type_suffix
|
|
16
|
+
'short_term'
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative '../similarity'
|
|
5
|
+
|
|
6
|
+
module RCrewAI
|
|
7
|
+
class Memory
|
|
8
|
+
# Persistent vector store backed by SQLite. Vectors are packed as
|
|
9
|
+
# little-endian floats; metadata is JSON. Cosine is computed in Ruby over
|
|
10
|
+
# rows filtered by scope — adequate for the thousands-of-memories scale an
|
|
11
|
+
# agent produces; an ANN index is a later optimization.
|
|
12
|
+
#
|
|
13
|
+
# The sqlite3 gem is required lazily so the rest of the library (and the
|
|
14
|
+
# in-memory store) works even if it isn't installed.
|
|
15
|
+
class SqliteStore
|
|
16
|
+
DEFAULT_PATH = File.join(Dir.home, '.rcrewai', 'memory.db')
|
|
17
|
+
|
|
18
|
+
# max_candidates bounds how many (most-recent) rows a search cosines, so
|
|
19
|
+
# recall cost stays constant as total memory grows. nil = consider all.
|
|
20
|
+
def initialize(path: DEFAULT_PATH, max_candidates: 1000)
|
|
21
|
+
require 'sqlite3'
|
|
22
|
+
ensure_parent_dir(path) unless path == ':memory:'
|
|
23
|
+
@db = SQLite3::Database.new(path)
|
|
24
|
+
@db.results_as_hash = true
|
|
25
|
+
@max_candidates = max_candidates
|
|
26
|
+
create_schema
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def add(id:, text:, vector:, scope:, metadata: {})
|
|
30
|
+
@db.execute(
|
|
31
|
+
'INSERT INTO memories (id, scope, text, vector, metadata) VALUES (?, ?, ?, ?, ?) ' \
|
|
32
|
+
'ON CONFLICT(id) DO UPDATE SET scope=excluded.scope, text=excluded.text, ' \
|
|
33
|
+
'vector=excluded.vector, metadata=excluded.metadata',
|
|
34
|
+
[id, scope, text, pack_vector(vector), JSON.generate(metadata || {})]
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def all(scope:)
|
|
39
|
+
@db.execute('SELECT * FROM memories WHERE scope = ?', [scope]).map { |row| to_record(row) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def search(vector, k:, scope:)
|
|
43
|
+
candidates(scope)
|
|
44
|
+
.reject { |r| r[:vector].nil? }
|
|
45
|
+
.map { |r| [r, Similarity.cosine(vector, r[:vector])] }
|
|
46
|
+
.sort_by { |(_r, score)| -score }
|
|
47
|
+
.first(k)
|
|
48
|
+
.map(&:first)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def delete(scope:)
|
|
52
|
+
@db.execute('DELETE FROM memories WHERE scope = ?', [scope])
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def delete_record(id:, scope:)
|
|
56
|
+
@db.execute('DELETE FROM memories WHERE id = ? AND scope = ?', [id, scope])
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# The rows a search will consider: the most-recent @max_candidates within
|
|
62
|
+
# the scope (rowid tracks insertion order). Bounds cosine cost at scale.
|
|
63
|
+
def candidates(scope)
|
|
64
|
+
sql = 'SELECT * FROM memories WHERE scope = ? ORDER BY rowid DESC'
|
|
65
|
+
params = [scope]
|
|
66
|
+
if @max_candidates
|
|
67
|
+
sql += ' LIMIT ?'
|
|
68
|
+
params << @max_candidates
|
|
69
|
+
end
|
|
70
|
+
@db.execute(sql, params).map { |row| to_record(row) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def create_schema
|
|
74
|
+
@db.execute(<<~SQL)
|
|
75
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
76
|
+
id TEXT PRIMARY KEY,
|
|
77
|
+
scope TEXT NOT NULL,
|
|
78
|
+
text TEXT,
|
|
79
|
+
vector BLOB,
|
|
80
|
+
metadata TEXT,
|
|
81
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
82
|
+
)
|
|
83
|
+
SQL
|
|
84
|
+
@db.execute('CREATE INDEX IF NOT EXISTS idx_memories_scope ON memories (scope)')
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def ensure_parent_dir(path)
|
|
88
|
+
require 'fileutils'
|
|
89
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def pack_vector(vector)
|
|
93
|
+
return nil if vector.nil?
|
|
94
|
+
|
|
95
|
+
vector.map(&:to_f).pack('e*')
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def unpack_vector(blob)
|
|
99
|
+
return nil if blob.nil?
|
|
100
|
+
|
|
101
|
+
blob.unpack('e*')
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def to_record(row)
|
|
105
|
+
{
|
|
106
|
+
id: row['id'],
|
|
107
|
+
text: row['text'],
|
|
108
|
+
vector: unpack_vector(row['vector']),
|
|
109
|
+
metadata: JSON.parse(row['metadata'] || '{}')
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base_memory'
|
|
4
|
+
|
|
5
|
+
module RCrewAI
|
|
6
|
+
class Memory
|
|
7
|
+
# Tool-call history and outcomes. Replaces the old @tool_usage array;
|
|
8
|
+
# persistable and searchable like the other memory types.
|
|
9
|
+
class ToolMemory < BaseMemory
|
|
10
|
+
def record_call(tool_name, params, result)
|
|
11
|
+
success = !result.to_s.downcase.include?('error')
|
|
12
|
+
text = "#{tool_name}(#{format_params(params)}) -> #{result}"
|
|
13
|
+
add(text, { 'tool' => tool_name, 'success' => success, 'result' => result.to_s })
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Most-recent-first usage records for a given tool.
|
|
17
|
+
def usage_for(tool_name, limit: 5)
|
|
18
|
+
@store.all(scope: @scope)
|
|
19
|
+
.select { |r| r[:metadata]['tool'] == tool_name }
|
|
20
|
+
.sort_by { |r| -r[:metadata]['seq'].to_i }
|
|
21
|
+
.first(limit)
|
|
22
|
+
.map { |r| r[:text] }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
protected
|
|
26
|
+
|
|
27
|
+
def type_suffix
|
|
28
|
+
'tool'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def format_params(params)
|
|
34
|
+
(params || {}).map { |k, v| "#{k}=#{v}" }.join(', ')
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|