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.
@@ -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