engram 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +118 -0
- data/README.md +409 -39
- data/lib/engram/adapters/in_memory_processed_turns.rb +40 -8
- data/lib/engram/adapters/in_memory_store.rb +49 -11
- data/lib/engram/adapters/null_embedder.rb +9 -0
- data/lib/engram/adapters/pgvector_store.rb +67 -20
- data/lib/engram/adapters/ruby_llm_embedder.rb +45 -4
- data/lib/engram/configuration.rb +5 -1
- data/lib/engram/consolidators/heuristic_consolidator.rb +7 -1
- data/lib/engram/consolidators/llm_consolidator.rb +44 -12
- data/lib/engram/embedding_metadata.rb +135 -0
- data/lib/engram/extractors/llm_extractor.rb +16 -6
- data/lib/engram/instrumentation.rb +57 -0
- data/lib/engram/memory.rb +42 -17
- data/lib/engram/memory_kind.rb +19 -0
- data/lib/engram/persistence.rb +39 -0
- data/lib/engram/persistence_policy.rb +45 -0
- data/lib/engram/ports/memory_store.rb +16 -8
- data/lib/engram/ports/processed_turns.rb +16 -8
- data/lib/engram/provenance.rb +257 -0
- data/lib/engram/rails/cache_processed_turns.rb +51 -10
- data/lib/engram/rails/observe_job.rb +5 -0
- data/lib/engram/rails/tasks.rake +26 -0
- data/lib/engram/railtie.rb +4 -0
- data/lib/engram/record.rb +8 -3
- data/lib/engram/reserved_metadata.rb +52 -0
- data/lib/engram/use_cases/forget.rb +6 -2
- data/lib/engram/use_cases/inject.rb +17 -3
- data/lib/engram/use_cases/observe.rb +83 -19
- data/lib/engram/use_cases/rebuild_embeddings.rb +189 -0
- data/lib/engram/use_cases/recall.rb +28 -9
- data/lib/engram/version.rb +1 -1
- data/lib/engram.rb +11 -0
- data/lib/generators/engram/install_generator.rb +10 -0
- data/lib/generators/engram/templates/create_engram_memories.rb.tt +10 -3
- metadata +14 -4
|
@@ -13,46 +13,84 @@ module Engram
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def add(record)
|
|
16
|
+
validate_scope!(record.scope)
|
|
17
|
+
|
|
16
18
|
record.id ||= (@sequence += 1)
|
|
17
19
|
@records[record.id] = record
|
|
18
20
|
record
|
|
19
21
|
end
|
|
20
22
|
|
|
21
|
-
def search(embedding:, scope:, limit:)
|
|
22
|
-
|
|
23
|
+
def search(embedding:, scope:, limit:, kinds: nil, embedding_metadata: nil)
|
|
24
|
+
Engram::EmbeddingMetadata.validate_query!(embedding, embedding_metadata)
|
|
25
|
+
allowed_kinds = normalize_kinds(kinds)
|
|
26
|
+
|
|
27
|
+
results = @records
|
|
23
28
|
.values
|
|
24
|
-
.select { |r| r
|
|
29
|
+
.select { |r| searchable?(r, scope, allowed_kinds) }
|
|
25
30
|
.map { |r| [r, Engram::Math.cosine_similarity(embedding, r.embedding)] }
|
|
26
31
|
.sort_by { |(_, score)| -score }
|
|
27
32
|
.first(limit)
|
|
28
33
|
.map { |(record, _)| record }
|
|
34
|
+
|
|
35
|
+
results.each { |record| Engram::EmbeddingMetadata.validate_record!(record, embedding, embedding_metadata) }
|
|
36
|
+
results
|
|
29
37
|
end
|
|
30
38
|
|
|
31
|
-
def all(scope:)
|
|
32
|
-
@records.values.select { |r| r.scope == scope }
|
|
39
|
+
def all(scope:, limit: nil, offset: 0, after_id: nil)
|
|
40
|
+
records = @records.values.select { |r| r.scope == scope }.sort_by { |record| record.id }
|
|
41
|
+
records = records.drop_while { |record| !after_id.nil? && record.id && record.id <= after_id }
|
|
42
|
+
records = records.drop(offset) if offset > 0
|
|
43
|
+
records = records.take(limit) if limit
|
|
44
|
+
records
|
|
33
45
|
end
|
|
34
46
|
|
|
35
|
-
def update(id:, record:)
|
|
36
|
-
|
|
47
|
+
def update(scope:, id:, record:)
|
|
48
|
+
existing = @records[id]
|
|
49
|
+
raise Engram::Error, "no memory with id #{id.inspect} in scope #{scope.inspect}" unless existing&.scope == scope
|
|
50
|
+
raise Engram::Error, "cannot move memory across scopes" unless record.scope == scope
|
|
37
51
|
|
|
38
52
|
record.id = id
|
|
39
53
|
@records[id] = record
|
|
40
54
|
end
|
|
41
55
|
|
|
42
|
-
def delete(id:)
|
|
56
|
+
def delete(scope:, id:)
|
|
57
|
+
return 0 unless @records[id]&.scope == scope
|
|
58
|
+
|
|
43
59
|
@records.delete(id)
|
|
60
|
+
1
|
|
44
61
|
end
|
|
45
62
|
|
|
46
|
-
def touch(id:, at: Time.now)
|
|
63
|
+
def touch(scope:, id:, at: Time.now)
|
|
47
64
|
record = @records[id]
|
|
48
|
-
|
|
49
|
-
|
|
65
|
+
return 0 unless record&.scope == scope
|
|
66
|
+
|
|
67
|
+
record.last_accessed_at = at
|
|
68
|
+
1
|
|
50
69
|
end
|
|
51
70
|
|
|
52
71
|
def clear
|
|
53
72
|
@records.clear
|
|
54
73
|
@sequence = 0
|
|
55
74
|
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def validate_scope!(scope)
|
|
79
|
+
raise Engram::Error, "memory scope cannot be nil" if scope.nil?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def searchable?(record, scope, allowed_kinds)
|
|
83
|
+
record.scope == scope && record.embedding && (allowed_kinds.nil? || allowed_kinds.include?(record.kind))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def normalize_kinds(kinds)
|
|
87
|
+
return nil if kinds.nil?
|
|
88
|
+
|
|
89
|
+
values = Array(kinds)
|
|
90
|
+
return nil if values.empty?
|
|
91
|
+
|
|
92
|
+
values.map { |kind| Engram::MemoryKind.normalize(kind) }
|
|
93
|
+
end
|
|
56
94
|
end
|
|
57
95
|
end
|
|
58
96
|
end
|
|
@@ -16,6 +16,15 @@ module Engram
|
|
|
16
16
|
|
|
17
17
|
attr_reader :dimensions
|
|
18
18
|
|
|
19
|
+
def embedding_metadata
|
|
20
|
+
Engram::EmbeddingMetadata.build(
|
|
21
|
+
adapter: self.class.name,
|
|
22
|
+
provider: "null",
|
|
23
|
+
model: "null-embedder-v1",
|
|
24
|
+
dimensions: dimensions
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
19
28
|
def embed(text)
|
|
20
29
|
seed = Digest::SHA256.hexdigest(text.to_s)
|
|
21
30
|
Array.new(@dimensions) do |i|
|
|
@@ -15,6 +15,8 @@ module Engram
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def add(record)
|
|
18
|
+
validate_scope!(record.scope)
|
|
19
|
+
|
|
18
20
|
row = model.create!(
|
|
19
21
|
content: record.content,
|
|
20
22
|
scope: record.scope,
|
|
@@ -26,40 +28,65 @@ module Engram
|
|
|
26
28
|
to_record(row)
|
|
27
29
|
end
|
|
28
30
|
|
|
29
|
-
def search(embedding:, scope:, limit:)
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
def search(embedding:, scope:, limit:, kinds: nil, embedding_metadata: nil)
|
|
32
|
+
Engram::EmbeddingMetadata.validate_query!(embedding, embedding_metadata)
|
|
33
|
+
query = model.where(scope: scope)
|
|
34
|
+
normalized_kinds = normalize_kinds(kinds)
|
|
35
|
+
query = query.where(kind: normalized_kinds) if normalized_kinds
|
|
36
|
+
|
|
37
|
+
query
|
|
32
38
|
.nearest_neighbors(:embedding, embedding, distance: "cosine")
|
|
33
39
|
.limit(limit)
|
|
34
40
|
.map { |row| to_record(row) }
|
|
41
|
+
.tap { |records| validate_query_records!(records, embedding, embedding_metadata) }
|
|
35
42
|
end
|
|
36
43
|
|
|
37
|
-
def all(scope:)
|
|
38
|
-
model.where(scope: scope).
|
|
44
|
+
def all(scope:, limit: nil, offset: 0, after_id: nil)
|
|
45
|
+
query = model.where(scope: scope).order(:id)
|
|
46
|
+
query = query.where("id > ?", after_id) if after_id
|
|
47
|
+
query = query.limit(limit) if limit
|
|
48
|
+
query = query.offset(offset) if offset > 0
|
|
49
|
+
query.map { |row| to_record(row) }
|
|
39
50
|
end
|
|
40
51
|
|
|
41
|
-
def update(id:, record:)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
52
|
+
def update(scope:, id:, record:)
|
|
53
|
+
raise Engram::Error, "cannot move memory across scopes" unless record.scope == scope
|
|
54
|
+
|
|
55
|
+
model.transaction do
|
|
56
|
+
row = model.lock.find_by!(id: id, scope: scope)
|
|
57
|
+
row.update!(
|
|
58
|
+
content: record.content,
|
|
59
|
+
kind: record.kind.to_s,
|
|
60
|
+
importance: record.importance,
|
|
61
|
+
metadata: record.metadata,
|
|
62
|
+
embedding: record.embedding
|
|
63
|
+
)
|
|
64
|
+
to_record(row)
|
|
65
|
+
end
|
|
66
|
+
rescue ActiveRecord::RecordNotFound
|
|
67
|
+
raise Engram::Error, "no memory with id #{id.inspect} in scope #{scope.inspect}"
|
|
51
68
|
end
|
|
52
69
|
|
|
53
|
-
def delete(id:)
|
|
54
|
-
model.where(id: id).delete_all
|
|
70
|
+
def delete(scope:, id:)
|
|
71
|
+
model.where(id: id, scope: scope).delete_all
|
|
55
72
|
end
|
|
56
73
|
|
|
57
|
-
def touch(id:, at: Time.now)
|
|
58
|
-
model.where(id: id).update_all(last_accessed_at: at)
|
|
74
|
+
def touch(scope:, id:, at: Time.now)
|
|
75
|
+
model.where(id: id, scope: scope).update_all(last_accessed_at: at)
|
|
59
76
|
end
|
|
60
77
|
|
|
61
78
|
private
|
|
62
79
|
|
|
80
|
+
def validate_scope!(scope)
|
|
81
|
+
raise Engram::Error, "memory scope cannot be nil" if scope.nil?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def validate_query_records!(records, embedding, embedding_metadata)
|
|
85
|
+
records.each do |record|
|
|
86
|
+
Engram::EmbeddingMetadata.validate_record!(record, embedding, embedding_metadata)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
63
90
|
def model
|
|
64
91
|
@model ||= resolve_default_model
|
|
65
92
|
end
|
|
@@ -78,13 +105,33 @@ module Engram
|
|
|
78
105
|
content: row.content,
|
|
79
106
|
scope: row.scope,
|
|
80
107
|
embedding: row.embedding,
|
|
81
|
-
kind:
|
|
108
|
+
kind: row.kind || :fact,
|
|
82
109
|
importance: row.importance || 1.0,
|
|
83
110
|
metadata: row.metadata || {},
|
|
84
111
|
created_at: row.created_at,
|
|
85
112
|
last_accessed_at: row.try(:last_accessed_at)
|
|
86
113
|
)
|
|
87
114
|
end
|
|
115
|
+
|
|
116
|
+
def normalize_kinds(kinds)
|
|
117
|
+
return nil if kinds.nil?
|
|
118
|
+
|
|
119
|
+
values = Array(kinds)
|
|
120
|
+
return nil if values.empty?
|
|
121
|
+
|
|
122
|
+
values
|
|
123
|
+
.map { |kind| Engram::MemoryKind.normalize(kind) }
|
|
124
|
+
.flat_map { |kind| persisted_kind_values(kind) }
|
|
125
|
+
.uniq
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def persisted_kind_values(kind)
|
|
129
|
+
# Include legacy rows persisted before canonical kind normalization.
|
|
130
|
+
legacy_aliases = Engram::MemoryKind::LEGACY_ALIASES
|
|
131
|
+
.select { |_, canonical| canonical == kind }
|
|
132
|
+
.keys
|
|
133
|
+
([kind] + legacy_aliases).map(&:to_s)
|
|
134
|
+
end
|
|
88
135
|
end
|
|
89
136
|
end
|
|
90
137
|
end
|
|
@@ -10,16 +10,32 @@ module Engram
|
|
|
10
10
|
DEFAULT_MODEL = "text-embedding-3-small"
|
|
11
11
|
DEFAULT_DIMENSIONS = 1536
|
|
12
12
|
|
|
13
|
-
def initialize(model: DEFAULT_MODEL, dimensions:
|
|
13
|
+
def initialize(model: DEFAULT_MODEL, dimensions: nil)
|
|
14
|
+
if dimensions && (!dimensions.is_a?(Integer) || !dimensions.positive?)
|
|
15
|
+
raise ArgumentError, "dimensions must be a positive integer"
|
|
16
|
+
end
|
|
17
|
+
|
|
14
18
|
@model = model
|
|
15
|
-
@
|
|
19
|
+
@requested_dimensions = dimensions
|
|
20
|
+
@dimensions = dimensions || DEFAULT_DIMENSIONS
|
|
16
21
|
end
|
|
17
22
|
|
|
18
|
-
attr_reader :dimensions
|
|
23
|
+
attr_reader :dimensions, :model
|
|
24
|
+
|
|
25
|
+
def embedding_metadata
|
|
26
|
+
Engram::EmbeddingMetadata.build(
|
|
27
|
+
adapter: self.class.name,
|
|
28
|
+
provider: "ruby_llm",
|
|
29
|
+
model: model,
|
|
30
|
+
dimensions: dimensions
|
|
31
|
+
)
|
|
32
|
+
end
|
|
19
33
|
|
|
20
34
|
def embed(text)
|
|
21
35
|
ensure_ruby_llm!
|
|
22
|
-
|
|
36
|
+
vectors = request_embedding(text)
|
|
37
|
+
validate_dimensions!(vectors)
|
|
38
|
+
vectors
|
|
23
39
|
end
|
|
24
40
|
|
|
25
41
|
private
|
|
@@ -30,6 +46,31 @@ module Engram
|
|
|
30
46
|
raise Engram::Error,
|
|
31
47
|
"RubyLLMEmbedder requires the `ruby_llm` gem. Add it to your Gemfile and configure it."
|
|
32
48
|
end
|
|
49
|
+
|
|
50
|
+
# Older RubyLLM versions do not accept the dimensions keyword.
|
|
51
|
+
def request_embedding(text)
|
|
52
|
+
if @requested_dimensions && embed_accepts_dimensions?
|
|
53
|
+
RubyLLM.embed(text, model: @model, dimensions: @requested_dimensions).vectors
|
|
54
|
+
else
|
|
55
|
+
RubyLLM.embed(text, model: @model).vectors
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def embed_accepts_dimensions?
|
|
60
|
+
RubyLLM.method(:embed).parameters.any? do |type, name|
|
|
61
|
+
type == :keyrest || ([:key, :keyreq].include?(type) && name == :dimensions)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def validate_dimensions!(vectors)
|
|
66
|
+
length = vectors.respond_to?(:length) ? vectors.length : nil
|
|
67
|
+
return if length == @dimensions
|
|
68
|
+
|
|
69
|
+
raise Engram::Error,
|
|
70
|
+
"embedding model #{@model.inspect} returned #{length ? "#{length}-dimension" : "non-vector"} output " \
|
|
71
|
+
"but the embedder is configured with dimensions: #{@dimensions}; align the dimensions option " \
|
|
72
|
+
"(and your vector column) with the model's output"
|
|
73
|
+
end
|
|
33
74
|
end
|
|
34
75
|
end
|
|
35
76
|
end
|
data/lib/engram/configuration.rb
CHANGED
|
@@ -7,7 +7,8 @@ module Engram
|
|
|
7
7
|
class Configuration
|
|
8
8
|
attr_accessor :store, :embedder, :completion, :default_limit,
|
|
9
9
|
:consolidator, :extraction_min_confidence, :processed_turns,
|
|
10
|
-
:importance_weight, :recency_weight, :recency_halflife, :touch_on_recall
|
|
10
|
+
:importance_weight, :recency_weight, :recency_halflife, :touch_on_recall,
|
|
11
|
+
:persistence_policy, :before_persist, :instrumentation_scope_identifier
|
|
11
12
|
|
|
12
13
|
def initialize
|
|
13
14
|
@store = Adapters::InMemoryStore.new
|
|
@@ -17,6 +18,9 @@ module Engram
|
|
|
17
18
|
@consolidator = :heuristic # :heuristic (deterministic) or :llm (LLM-as-judge)
|
|
18
19
|
@extraction_min_confidence = 0.5
|
|
19
20
|
@processed_turns = Adapters::InMemoryProcessedTurns.new # idempotency for observe
|
|
21
|
+
@persistence_policy = PersistencePolicy.new
|
|
22
|
+
@before_persist = nil
|
|
23
|
+
@instrumentation_scope_identifier = nil
|
|
20
24
|
|
|
21
25
|
# Recall ranking. With both weights at 0.0, recall is plain similarity search.
|
|
22
26
|
@importance_weight = 0.0
|
|
@@ -15,7 +15,13 @@ module Engram
|
|
|
15
15
|
|
|
16
16
|
def reconcile_all(candidates:, scope:)
|
|
17
17
|
Array(candidates).map do |candidate|
|
|
18
|
-
nearest =
|
|
18
|
+
nearest = Engram::EmbeddingMetadata.search(
|
|
19
|
+
@store,
|
|
20
|
+
embedding: candidate.embedding,
|
|
21
|
+
embedding_metadata: Engram::EmbeddingMetadata.extract(candidate.metadata),
|
|
22
|
+
scope: scope,
|
|
23
|
+
limit: 1
|
|
24
|
+
).first
|
|
19
25
|
similarity = nearest ? Engram::Math.cosine_similarity(candidate.embedding, nearest.embedding) : 0.0
|
|
20
26
|
|
|
21
27
|
if nearest && similarity >= @similarity_threshold
|
|
@@ -21,20 +21,25 @@ module Engram
|
|
|
21
21
|
Return one decision per candidate, referencing it by its index.
|
|
22
22
|
PROMPT
|
|
23
23
|
|
|
24
|
+
# Shaped for OpenAI strict structured outputs: every object sets
|
|
25
|
+
# additionalProperties: false and lists all of its properties in `required`. target_id
|
|
26
|
+
# is nullable because add/noop decisions reference no existing memory.
|
|
24
27
|
SCHEMA = {
|
|
25
28
|
type: "object",
|
|
29
|
+
additionalProperties: false,
|
|
26
30
|
properties: {
|
|
27
31
|
decisions: {
|
|
28
32
|
type: "array",
|
|
29
33
|
items: {
|
|
30
34
|
type: "object",
|
|
35
|
+
additionalProperties: false,
|
|
31
36
|
properties: {
|
|
32
37
|
index: {type: "integer"},
|
|
33
38
|
action: {type: "string", enum: %w[add update forget noop]},
|
|
34
|
-
target_id: {type: %w[integer
|
|
39
|
+
target_id: {type: %w[integer null]},
|
|
35
40
|
reason: {type: "string"}
|
|
36
41
|
},
|
|
37
|
-
required: %w[index action]
|
|
42
|
+
required: %w[index action target_id reason]
|
|
38
43
|
}
|
|
39
44
|
}
|
|
40
45
|
},
|
|
@@ -51,23 +56,35 @@ module Engram
|
|
|
51
56
|
candidates = Array(candidates)
|
|
52
57
|
return [] if candidates.empty?
|
|
53
58
|
|
|
59
|
+
neighbors = neighbor_map(candidates, scope)
|
|
54
60
|
result = @completion.complete(
|
|
55
61
|
system: SYSTEM,
|
|
56
|
-
user: JSON.generate(payload(candidates,
|
|
62
|
+
user: JSON.generate(payload(candidates, neighbors)),
|
|
57
63
|
schema: SCHEMA
|
|
58
64
|
)
|
|
59
|
-
map_decisions(decisions(result), candidates)
|
|
65
|
+
map_decisions(decisions(result), candidates, neighbors)
|
|
60
66
|
end
|
|
61
67
|
|
|
62
68
|
private
|
|
63
69
|
|
|
64
|
-
def
|
|
70
|
+
def neighbor_map(candidates, scope)
|
|
71
|
+
candidates.map do |candidate|
|
|
72
|
+
Engram::EmbeddingMetadata.search(
|
|
73
|
+
@store,
|
|
74
|
+
embedding: candidate.embedding,
|
|
75
|
+
embedding_metadata: Engram::EmbeddingMetadata.extract(candidate.metadata),
|
|
76
|
+
scope: scope,
|
|
77
|
+
limit: @neighbors
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def payload(candidates, neighbors)
|
|
65
83
|
items = candidates.each_with_index.map do |candidate, index|
|
|
66
|
-
existing = @store.search(embedding: candidate.embedding, scope: scope, limit: @neighbors)
|
|
67
84
|
{
|
|
68
85
|
index: index,
|
|
69
86
|
candidate: candidate.content,
|
|
70
|
-
existing:
|
|
87
|
+
existing: neighbors[index].map { |r| {id: r.id, content: r.content} }
|
|
71
88
|
}
|
|
72
89
|
end
|
|
73
90
|
{candidates: items}
|
|
@@ -76,19 +93,34 @@ module Engram
|
|
|
76
93
|
def decisions(result)
|
|
77
94
|
return [] unless result.is_a?(Hash)
|
|
78
95
|
|
|
79
|
-
result["decisions"] || result[:decisions] || []
|
|
96
|
+
decisions = result["decisions"] || result[:decisions] || []
|
|
97
|
+
decisions.is_a?(Array) ? decisions : []
|
|
80
98
|
end
|
|
81
99
|
|
|
82
|
-
def map_decisions(raw, candidates)
|
|
100
|
+
def map_decisions(raw, candidates, neighbors)
|
|
101
|
+
seen = Set.new
|
|
83
102
|
raw.filter_map do |decision|
|
|
103
|
+
next unless decision.is_a?(Hash)
|
|
104
|
+
|
|
84
105
|
decision = decision.transform_keys(&:to_s)
|
|
85
106
|
index = decision["index"]
|
|
86
|
-
next unless index && candidates[index]
|
|
107
|
+
next unless index.is_a?(Integer) && index >= 0 && candidates[index]
|
|
108
|
+
next if seen.include?(index)
|
|
109
|
+
|
|
110
|
+
action = (decision["action"] || "noop").to_s
|
|
111
|
+
next unless Engram::Decision::ACTIONS.include?(action.to_sym)
|
|
112
|
+
|
|
113
|
+
target_id = decision["target_id"]
|
|
114
|
+
if %w[update forget].include?(action)
|
|
115
|
+
next if target_id.nil?
|
|
116
|
+
next unless neighbors[index].any? { |neighbor| neighbor.id == target_id }
|
|
117
|
+
end
|
|
87
118
|
|
|
119
|
+
seen.add(index)
|
|
88
120
|
Engram::Decision.new(
|
|
89
|
-
action:
|
|
121
|
+
action: action.to_sym,
|
|
90
122
|
candidate: candidates[index],
|
|
91
|
-
target_id:
|
|
123
|
+
target_id: target_id,
|
|
92
124
|
reason: decision["reason"]
|
|
93
125
|
)
|
|
94
126
|
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Engram
|
|
6
|
+
# Helpers for storing and validating embedding provenance under Engram's
|
|
7
|
+
# reserved metadata namespace.
|
|
8
|
+
module EmbeddingMetadata
|
|
9
|
+
RESERVED_KEY = "_engram"
|
|
10
|
+
EMBEDDING_KEY = "embedding"
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def build(adapter:, dimensions:, model: nil, provider: nil)
|
|
15
|
+
data = {
|
|
16
|
+
"adapter" => adapter.to_s,
|
|
17
|
+
"dimensions" => dimensions
|
|
18
|
+
}
|
|
19
|
+
data["provider"] = provider.to_s if provider
|
|
20
|
+
data["model"] = model.to_s if model
|
|
21
|
+
data["fingerprint"] = fingerprint(data)
|
|
22
|
+
data
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def for_embedder(embedder, embedding: nil)
|
|
26
|
+
return nil unless embedder.respond_to?(:embedding_metadata)
|
|
27
|
+
|
|
28
|
+
metadata = stringify_keys(embedder.embedding_metadata || {})
|
|
29
|
+
return nil if metadata.empty?
|
|
30
|
+
|
|
31
|
+
dimensions = embedding.respond_to?(:length) ? embedding.length : metadata["dimensions"]
|
|
32
|
+
metadata = metadata.merge("dimensions" => dimensions) if dimensions
|
|
33
|
+
metadata["fingerprint"] = fingerprint(metadata)
|
|
34
|
+
metadata
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def attach(record, embedder: nil, embedding_metadata: nil)
|
|
38
|
+
metadata = stringify_keys(embedding_metadata || for_embedder(embedder, embedding: record.embedding)) || {}
|
|
39
|
+
return record if metadata.empty?
|
|
40
|
+
|
|
41
|
+
record.with(metadata: merge(record.metadata, metadata))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def extract(metadata)
|
|
45
|
+
metadata = stringify_keys(metadata || {})
|
|
46
|
+
reserved = metadata[RESERVED_KEY]
|
|
47
|
+
return {} unless reserved.is_a?(Hash)
|
|
48
|
+
|
|
49
|
+
stringify_keys(reserved[EMBEDDING_KEY] || {})
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def merge(metadata, embedding_metadata)
|
|
53
|
+
Engram::ReservedMetadata.attach(
|
|
54
|
+
metadata, EMBEDDING_KEY, embedding_metadata || {}, namespace_description: "embedding metadata"
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def search(store, embedding:, embedding_metadata:, scope:, limit:, kinds: nil)
|
|
59
|
+
store.search(
|
|
60
|
+
embedding: embedding,
|
|
61
|
+
embedding_metadata: embedding_metadata,
|
|
62
|
+
scope: scope,
|
|
63
|
+
limit: limit,
|
|
64
|
+
kinds: kinds
|
|
65
|
+
)
|
|
66
|
+
rescue ArgumentError => error
|
|
67
|
+
raise unless unknown_embedding_metadata_keyword?(error)
|
|
68
|
+
|
|
69
|
+
store.search(embedding: embedding, scope: scope, limit: limit, kinds: kinds)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def validate_query!(embedding, embedding_metadata)
|
|
73
|
+
metadata = stringify_keys(embedding_metadata || {})
|
|
74
|
+
return if metadata.empty?
|
|
75
|
+
|
|
76
|
+
expected = metadata["dimensions"]
|
|
77
|
+
return unless expected && embedding.respond_to?(:length) && embedding.length != expected.to_i
|
|
78
|
+
|
|
79
|
+
raise Engram::Error,
|
|
80
|
+
"embedding dimension mismatch: query vector has #{embedding.length} dimensions, metadata declares #{expected}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def validate_record!(record, query_embedding, query_metadata)
|
|
84
|
+
if query_embedding.respond_to?(:length) && record.embedding.respond_to?(:length) &&
|
|
85
|
+
query_embedding.length != record.embedding.length
|
|
86
|
+
raise Engram::Error,
|
|
87
|
+
"embedding dimension mismatch: query vector has #{query_embedding.length} dimensions, " \
|
|
88
|
+
"record #{record.id.inspect} has #{record.embedding.length}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
stored = extract(record.metadata)
|
|
92
|
+
return if stored.empty?
|
|
93
|
+
|
|
94
|
+
stored_dimensions = stored["dimensions"]
|
|
95
|
+
if stored_dimensions && record.embedding.respond_to?(:length) && record.embedding.length != stored_dimensions.to_i
|
|
96
|
+
raise Engram::Error,
|
|
97
|
+
"embedding metadata mismatch: record #{record.id.inspect} has #{record.embedding.length} dimensions, " \
|
|
98
|
+
"metadata declares #{stored_dimensions}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
query_metadata = stringify_keys(query_metadata || {})
|
|
102
|
+
return if query_metadata.empty?
|
|
103
|
+
|
|
104
|
+
conflicting_key = %w[adapter provider model dimensions fingerprint].find do |key|
|
|
105
|
+
stored.key?(key) && query_metadata.key?(key) && stored[key].to_s != query_metadata[key].to_s
|
|
106
|
+
end
|
|
107
|
+
return unless conflicting_key
|
|
108
|
+
|
|
109
|
+
raise Engram::Error,
|
|
110
|
+
"embedding metadata mismatch for record #{record.id.inspect}: #{conflicting_key} " \
|
|
111
|
+
"#{stored[conflicting_key].inspect} does not match query #{query_metadata[conflicting_key].inspect}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def fingerprint(metadata)
|
|
115
|
+
relevant = stringify_keys(metadata).values_at("adapter", "provider", "model", "dimensions")
|
|
116
|
+
Digest::SHA256.hexdigest(relevant.map { |value| value.nil? ? "" : value.to_s }.join("\0"))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def stringify_keys(value)
|
|
120
|
+
case value
|
|
121
|
+
when Hash
|
|
122
|
+
value.each_with_object({}) do |(key, nested), out|
|
|
123
|
+
out[key.to_s] = stringify_keys(nested)
|
|
124
|
+
end
|
|
125
|
+
else
|
|
126
|
+
value
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def unknown_embedding_metadata_keyword?(error)
|
|
131
|
+
error.message.include?("unknown keyword: :embedding_metadata") ||
|
|
132
|
+
error.message.include?("unknown keyword: \"embedding_metadata\"")
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -12,24 +12,32 @@ module Engram
|
|
|
12
12
|
- Only stable facts about the user (preferences, attributes, decisions, history).
|
|
13
13
|
- Ignore ephemeral chit-chat, questions, and the assistant's own messages.
|
|
14
14
|
- Normalize each fact to a terse third-person statement (e.g. "User is on the Pro plan").
|
|
15
|
+
- Classify kind as fact, preference, instruction, or episodic.
|
|
16
|
+
- Do not extract secrets, API keys, passwords, tokens, or transient task progress.
|
|
15
17
|
- Set confidence in [0,1]; importance in [0,1].
|
|
16
18
|
Return an empty list if there is nothing worth remembering.
|
|
17
19
|
PROMPT
|
|
18
20
|
|
|
21
|
+
# Shaped for OpenAI strict structured outputs: every object sets
|
|
22
|
+
# additionalProperties: false and lists all of its properties in `required`. The
|
|
23
|
+
# extractor still defends against missing/empty fields, so requiring them here only
|
|
24
|
+
# constrains the model's output, it does not change downstream behaviour.
|
|
19
25
|
SCHEMA = {
|
|
20
26
|
type: "object",
|
|
27
|
+
additionalProperties: false,
|
|
21
28
|
properties: {
|
|
22
29
|
facts: {
|
|
23
30
|
type: "array",
|
|
24
31
|
items: {
|
|
25
32
|
type: "object",
|
|
33
|
+
additionalProperties: false,
|
|
26
34
|
properties: {
|
|
27
35
|
content: {type: "string"},
|
|
28
|
-
kind: {type: "string", enum: %w[
|
|
36
|
+
kind: {type: "string", enum: %w[fact preference instruction episodic semantic]},
|
|
29
37
|
importance: {type: "number"},
|
|
30
38
|
confidence: {type: "number"}
|
|
31
39
|
},
|
|
32
|
-
required: %w[content]
|
|
40
|
+
required: %w[content kind importance confidence]
|
|
33
41
|
}
|
|
34
42
|
}
|
|
35
43
|
},
|
|
@@ -50,13 +58,15 @@ module Engram
|
|
|
50
58
|
next if content.empty?
|
|
51
59
|
next if (fact["confidence"] || 1.0).to_f < @min_confidence
|
|
52
60
|
|
|
53
|
-
|
|
61
|
+
embedding = @embedder.embed(content)
|
|
62
|
+
Engram::EmbeddingMetadata.attach(Engram::Record.new(
|
|
54
63
|
content: content,
|
|
55
64
|
scope: scope,
|
|
56
|
-
kind:
|
|
65
|
+
kind: fact["kind"] || "fact",
|
|
57
66
|
importance: (fact["importance"] || 1.0).to_f,
|
|
58
|
-
|
|
59
|
-
|
|
67
|
+
metadata: {confidence: (fact["confidence"] || 1.0).to_f},
|
|
68
|
+
embedding: embedding
|
|
69
|
+
), embedder: @embedder)
|
|
60
70
|
end
|
|
61
71
|
end
|
|
62
72
|
|