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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +118 -0
  3. data/README.md +409 -39
  4. data/lib/engram/adapters/in_memory_processed_turns.rb +40 -8
  5. data/lib/engram/adapters/in_memory_store.rb +49 -11
  6. data/lib/engram/adapters/null_embedder.rb +9 -0
  7. data/lib/engram/adapters/pgvector_store.rb +67 -20
  8. data/lib/engram/adapters/ruby_llm_embedder.rb +45 -4
  9. data/lib/engram/configuration.rb +5 -1
  10. data/lib/engram/consolidators/heuristic_consolidator.rb +7 -1
  11. data/lib/engram/consolidators/llm_consolidator.rb +44 -12
  12. data/lib/engram/embedding_metadata.rb +135 -0
  13. data/lib/engram/extractors/llm_extractor.rb +16 -6
  14. data/lib/engram/instrumentation.rb +57 -0
  15. data/lib/engram/memory.rb +42 -17
  16. data/lib/engram/memory_kind.rb +19 -0
  17. data/lib/engram/persistence.rb +39 -0
  18. data/lib/engram/persistence_policy.rb +45 -0
  19. data/lib/engram/ports/memory_store.rb +16 -8
  20. data/lib/engram/ports/processed_turns.rb +16 -8
  21. data/lib/engram/provenance.rb +257 -0
  22. data/lib/engram/rails/cache_processed_turns.rb +51 -10
  23. data/lib/engram/rails/observe_job.rb +5 -0
  24. data/lib/engram/rails/tasks.rake +26 -0
  25. data/lib/engram/railtie.rb +4 -0
  26. data/lib/engram/record.rb +8 -3
  27. data/lib/engram/reserved_metadata.rb +52 -0
  28. data/lib/engram/use_cases/forget.rb +6 -2
  29. data/lib/engram/use_cases/inject.rb +17 -3
  30. data/lib/engram/use_cases/observe.rb +83 -19
  31. data/lib/engram/use_cases/rebuild_embeddings.rb +189 -0
  32. data/lib/engram/use_cases/recall.rb +28 -9
  33. data/lib/engram/version.rb +1 -1
  34. data/lib/engram.rb +11 -0
  35. data/lib/generators/engram/install_generator.rb +10 -0
  36. data/lib/generators/engram/templates/create_engram_memories.rb.tt +10 -3
  37. 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
- @records
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.scope == scope && r.embedding }
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
- raise Engram::Error, "no memory with id #{id.inspect}" unless @records.key?(id)
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
- record.last_accessed_at = at if record
49
- record
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
- model
31
- .where(scope: scope)
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).map { |row| to_record(row) }
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
- row = model.find(id)
43
- row.update!(
44
- content: record.content,
45
- kind: record.kind.to_s,
46
- importance: record.importance,
47
- metadata: record.metadata,
48
- embedding: record.embedding
49
- )
50
- to_record(row)
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: (row.kind || :semantic).to_sym,
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: DEFAULT_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
- @dimensions = dimensions
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
- RubyLLM.embed(text, model: @model).vectors
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
@@ -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 = @store.search(embedding: candidate.embedding, scope: scope, limit: 1).first
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 string null]},
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, scope)),
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 payload(candidates, scope)
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: existing.map { |r| {id: r.id, content: r.content} }
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: (decision["action"] || "noop").to_sym,
121
+ action: action.to_sym,
90
122
  candidate: candidates[index],
91
- target_id: decision["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[semantic episodic preference]},
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
- Engram::Record.new(
61
+ embedding = @embedder.embed(content)
62
+ Engram::EmbeddingMetadata.attach(Engram::Record.new(
54
63
  content: content,
55
64
  scope: scope,
56
- kind: (fact["kind"] || "semantic").to_sym,
65
+ kind: fact["kind"] || "fact",
57
66
  importance: (fact["importance"] || 1.0).to_f,
58
- embedding: @embedder.embed(content)
59
- )
67
+ metadata: {confidence: (fact["confidence"] || 1.0).to_f},
68
+ embedding: embedding
69
+ ), embedder: @embedder)
60
70
  end
61
71
  end
62
72