engram 0.4.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.
@@ -19,5 +19,9 @@ module Engram
19
19
  require "engram/rails/observe_job"
20
20
  end
21
21
  end
22
+
23
+ rake_tasks do
24
+ load File.expand_path("rails/tasks.rake", __dir__)
25
+ end
22
26
  end
23
27
  end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ # Collision-preserving operations for schemas sharing Engram's metadata namespace.
5
+ module ReservedMetadata
6
+ KEY = "_engram"
7
+
8
+ module_function
9
+
10
+ def attach(metadata, schema_key, value, namespace_description: "metadata")
11
+ metadata = (metadata || {}).dup
12
+ reserved_values = [metadata.delete(KEY), metadata.delete(:_engram)].compact
13
+ unless reserved_values.all? { |reserved| reserved.is_a?(Hash) }
14
+ raise Engram::Error, "metadata key #{KEY.inspect} is reserved for Engram #{namespace_description}"
15
+ end
16
+
17
+ reserved = reserved_values.reduce({}) do |merged, reserved_value|
18
+ siblings = reserved_value.reject { |key, _nested| key.to_s == schema_key }
19
+ merge(merged, normalize(siblings))
20
+ end
21
+ metadata.merge(KEY => reserved.merge(schema_key => normalize(value)))
22
+ end
23
+
24
+ def normalize(value, path = [])
25
+ return value.map { |nested| normalize(nested, path) } if value.is_a?(Array)
26
+ return value unless value.is_a?(Hash)
27
+
28
+ value.each_with_object({}) do |(key, nested), normalized|
29
+ string_key = key.to_s
30
+ normalized_value = normalize(nested, path + [string_key])
31
+ normalized[string_key] = if normalized.key?(string_key)
32
+ merge_value(normalized[string_key], normalized_value, path + [string_key])
33
+ else
34
+ normalized_value
35
+ end
36
+ end
37
+ end
38
+
39
+ def merge(left, right, path = [])
40
+ right.each_with_object(left.dup) do |(key, value), merged|
41
+ merged[key] = merged.key?(key) ? merge_value(merged[key], value, path + [key]) : value
42
+ end
43
+ end
44
+
45
+ def merge_value(left, right, path)
46
+ return merge(left, right, path) if left.is_a?(Hash) && right.is_a?(Hash)
47
+ return left if left == right
48
+
49
+ raise Engram::Error, "conflicting reserved metadata at #{path.join(".")}"
50
+ end
51
+ end
52
+ end
@@ -20,8 +20,12 @@ module Engram
20
20
  timestamp && timestamp < cutoff && record.importance.to_f < min_importance
21
21
  end
22
22
 
23
- stale.each { |record| @store.delete(id: record.id) if record.id }
24
- stale
23
+ stale.select do |record|
24
+ next false unless record.id
25
+
26
+ deleted = @store.delete(scope: scope, id: record.id)
27
+ deleted && deleted != 0
28
+ end
25
29
  end
26
30
  end
27
31
  end
@@ -26,38 +26,54 @@ module Engram
26
26
  idempotency_key_present: !idempotency_key.nil?
27
27
  )
28
28
  Engram::Instrumentation.instrument("observe", payload) do
29
- if already_processed?(idempotency_key)
29
+ claim = acquire_claim(scope, idempotency_key)
30
+ if idempotency_key && @processed_turns && !claim
31
+ unless @processed_turns.completed?(scope: scope, key: idempotency_key)
32
+ # A live, incomplete lease is retryable, not a completed duplicate.
33
+ raise Engram::ObservationInProgressError,
34
+ "observation for this turn is claimed but not completed; retry after the claim lease expires"
35
+ end
36
+
30
37
  payload[:skipped] = true
31
38
  payload[:candidate_count] = 0
32
39
  payload[:decision_count] = 0
33
40
  next []
34
41
  end
35
42
 
36
- candidates = extract(messages: messages, scope: scope)
37
- payload[:candidate_count] = candidates.size
38
- if candidates.empty?
39
- mark_processed(idempotency_key)
40
- payload[:decision_count] = 0
41
- next []
42
- end
43
+ begin
44
+ candidates = extract(messages: messages, scope: scope)
45
+ payload[:candidate_count] = candidates.size
46
+ if candidates.empty?
47
+ complete_claim(scope, idempotency_key, claim)
48
+ payload[:decision_count] = 0
49
+ next []
50
+ end
43
51
 
44
- decisions = consolidate(candidates: candidates, scope: scope)
45
- applied_decisions = decisions.filter_map { |decision| apply(decision) }
46
- payload[:decision_count] = applied_decisions.size
47
- payload[:decision_actions] = applied_decisions.map { |decision| decision.action.to_s }
48
- mark_processed(idempotency_key)
49
- applied_decisions
52
+ decisions = consolidate(candidates: candidates, scope: scope)
53
+ applied_decisions = decisions.filter_map { |decision| apply(decision, scope) }
54
+ payload[:decision_count] = applied_decisions.size
55
+ payload[:decision_actions] = applied_decisions.map { |decision| decision.action.to_s }
56
+ complete_claim(scope, idempotency_key, claim)
57
+ applied_decisions
58
+ rescue
59
+ release_claim(scope, idempotency_key, claim)
60
+ raise
61
+ end
50
62
  end
51
63
  end
52
64
 
53
65
  private
54
66
 
55
- def already_processed?(key)
56
- !!(key && @processed_turns&.seen?(key))
67
+ def acquire_claim(scope, key)
68
+ @processed_turns.claim(scope: scope, key: key) if key && @processed_turns
69
+ end
70
+
71
+ def complete_claim(scope, key, claim)
72
+ @processed_turns.complete(scope: scope, key: key, claim: claim) if claim
57
73
  end
58
74
 
59
- def mark_processed(key)
60
- @processed_turns.record(key) if key && @processed_turns
75
+ def release_claim(scope, key, claim)
76
+ @processed_turns.release(scope: scope, key: key, claim: claim) if claim
61
77
  end
62
78
 
63
79
  def extract(messages:, scope:)
@@ -79,16 +95,21 @@ module Engram
79
95
  end
80
96
  end
81
97
 
82
- def apply(decision)
98
+ def apply(decision, scope)
83
99
  case decision.action
84
100
  when :add
85
- decision if persistence.add(decision.candidate)
101
+ raise Engram::Error, "cannot move memory across scopes" unless decision.candidate.scope == scope
102
+
103
+ decision if persistence.add(decision.candidate, scope: scope)
86
104
  when :update
87
- if decision.target_id && persistence.update(id: decision.target_id, record: decision.candidate)
105
+ if decision.target_id && persistence.update(scope: scope, id: decision.target_id, record: decision.candidate)
88
106
  decision
89
107
  end
90
108
  when :forget
91
- decision if decision.target_id && @store.delete(id: decision.target_id)
109
+ if decision.target_id
110
+ deleted = @store.delete(scope: scope, id: decision.target_id)
111
+ decision if deleted && deleted != 0
112
+ end
92
113
  when :noop
93
114
  nil
94
115
  end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ module UseCases
5
+ # Rebuild stored embeddings when the active embedder configuration changes.
6
+ # Returns counts useful for logging/observability:
7
+ # - :processed: total rows examined in scope
8
+ # - :updated: rows re-embedded and persisted
9
+ # - :skipped: rows skipped because they were unchanged or incomplete
10
+ # - :failed: count of rows that failed during rebuild
11
+ # - :failed_ids: record IDs that failed during rebuild
12
+ # - :failed_errors: error class/message keyed by failed record ID
13
+ class RebuildEmbeddings
14
+ def initialize(store:, embedder:)
15
+ @store = store
16
+ @embedder = embedder
17
+ end
18
+
19
+ def call(scope:, stale_only: true, batch_size: 100)
20
+ batch_size = Integer(batch_size)
21
+ raise ArgumentError, "batch_size must be greater than 0" unless batch_size.positive?
22
+
23
+ counts = {
24
+ processed: 0,
25
+ updated: 0,
26
+ skipped: 0,
27
+ failed: 0,
28
+ failed_ids: [],
29
+ failed_errors: {}
30
+ }
31
+
32
+ after_id = nil
33
+ legacy_index = 0
34
+ legacy_records = nil
35
+ processed_ids = Set.new
36
+ processed_nil_records = Set.new
37
+ loop do
38
+ batch_result = fetch_batch(scope:, batch_size:, after_id:, legacy_records:, legacy_index:)
39
+ legacy_records = batch_result[:legacy_records] if batch_result[:legacy_records]
40
+ batch = batch_result[:records]
41
+ if batch.empty?
42
+ break if legacy_records
43
+
44
+ # Reconcile with one stable snapshot: accepting keyset keywords does
45
+ # not guarantee that an adapter returns complete, ordered pages.
46
+ legacy_records = Array(@store.all(scope: scope))
47
+ legacy_index = 0
48
+ next
49
+ end
50
+
51
+ cursor_record = batch.reverse.find { |candidate| !candidate.id.nil? } unless legacy_records
52
+
53
+ if !legacy_records && fallback_before_processing?(batch:, after_id:)
54
+ legacy_records = Array(@store.all(scope: scope))
55
+ legacy_index = 0
56
+ batch = legacy_records.slice(legacy_index, batch_size) || []
57
+ break if batch.empty?
58
+ end
59
+
60
+ batch.each do |record|
61
+ identity = record.id.nil? ? record.object_id : record.id
62
+ seen = record.id.nil? ? processed_nil_records : processed_ids
63
+ next unless seen.add?(identity)
64
+
65
+ counts[:processed] += 1
66
+
67
+ if record.id.nil?
68
+ counts[:skipped] += 1
69
+ next
70
+ end
71
+
72
+ if stale_only && !stale?(record)
73
+ counts[:skipped] += 1
74
+ next
75
+ end
76
+
77
+ begin
78
+ rebuilt = record.with(embedding: @embedder.embed(record.content))
79
+ rebuilt = EmbeddingMetadata.attach(rebuilt, embedder: @embedder)
80
+ updated = @store.update(scope: scope, id: record.id, record: rebuilt)
81
+ raise Engram::Error, "memory update was not applied" unless updated && updated != 0
82
+
83
+ counts[:updated] += 1
84
+ rescue => error
85
+ counts[:failed] += 1
86
+ record_id = record.id
87
+ counts[:failed_ids] << record_id
88
+ counts[:failed_errors][record_id] = {
89
+ class: error.class.name,
90
+ message: error.message
91
+ }
92
+ warn "Rebuild failed for record ##{record_id}: #{error.class}: #{error.message}"
93
+ end
94
+ end
95
+
96
+ if legacy_records
97
+ legacy_index += batch.length
98
+ next
99
+ end
100
+
101
+ if cursor_record.nil?
102
+ legacy_records = Array(@store.all(scope: scope))
103
+ legacy_index = 0
104
+ next
105
+ end
106
+
107
+ if fallback_to_legacy_batching?(batch:, cursor_record:)
108
+ legacy_records = Array(@store.all(scope: scope))
109
+ legacy_index = 0
110
+ next
111
+ end
112
+
113
+ after_id = cursor_record.id
114
+ end
115
+
116
+ {scope: scope, **counts}
117
+ end
118
+
119
+ private
120
+
121
+ def fetch_batch(scope:, batch_size:, after_id:, legacy_records:, legacy_index:)
122
+ if legacy_records
123
+ return {records: legacy_records.slice(legacy_index, batch_size) || [], legacy_records:}
124
+ end
125
+
126
+ records = modern_batch(scope:, batch_size:, after_id:)
127
+ return {records:} if records
128
+
129
+ legacy_records = Array(@store.all(scope: scope))
130
+ {records: legacy_records.slice(legacy_index, batch_size) || [], legacy_records:}
131
+ end
132
+
133
+ def modern_batch(scope:, batch_size:, after_id:)
134
+ return unless batched_all_supported?
135
+
136
+ @store.all(scope: scope, limit: batch_size, after_id: after_id)
137
+ end
138
+
139
+ def batched_all_supported?
140
+ parameters = @store.method(:all).parameters
141
+ keyword_names = parameters.filter_map do |kind, name|
142
+ name if [:keyreq, :key].include?(kind)
143
+ end
144
+
145
+ keyword_names.include?(:limit) && keyword_names.include?(:after_id)
146
+ end
147
+
148
+ def fallback_to_legacy_batching?(batch:, cursor_record:)
149
+ cursor_record != batch.last
150
+ end
151
+
152
+ def fallback_before_processing?(batch:, after_id:)
153
+ ids = batch.filter_map(&:id)
154
+ return true if ids.uniq.length != ids.length
155
+ return false if after_id.nil?
156
+ return true if batch.any? { |record| record.id.nil? }
157
+
158
+ batch.any? { |record| record.id && record.id <= after_id }
159
+ end
160
+
161
+ def stale?(record)
162
+ stored = EmbeddingMetadata.extract(record.metadata)
163
+ return true if stored.empty?
164
+
165
+ expected = expected_metadata(record)
166
+ return true unless expected
167
+
168
+ required = %w[adapter provider model dimensions fingerprint]
169
+ return true if required.any? { |key| stored[key] != expected[key] }
170
+
171
+ return true if stored["dimensions"] &&
172
+ record.embedding.respond_to?(:length) &&
173
+ record.embedding.length != stored["dimensions"].to_i
174
+
175
+ false
176
+ end
177
+
178
+ # What the active embedder would produce for a rebuilt row. Declared dimensions take
179
+ # precedence so dimension-only configuration drift marks rows stale; fall back to the
180
+ # record's current vector length for embedders that do not declare dimensions.
181
+ def expected_metadata(record)
182
+ declared = EmbeddingMetadata.for_embedder(@embedder)
183
+ return declared if declared.nil? || declared["dimensions"]
184
+
185
+ EmbeddingMetadata.for_embedder(@embedder, embedding: record.embedding)
186
+ end
187
+ end
188
+ end
189
+ end
@@ -36,11 +36,19 @@ module Engram
36
36
  )
37
37
  Engram::Instrumentation.instrument("recall", payload) do
38
38
  embedding = @embedder.embed(query)
39
+ embedding_metadata = Engram::EmbeddingMetadata.for_embedder(@embedder, embedding: embedding)
39
40
  pool_limit = reranking? ? limit * @pool_factor : limit
40
- pool = @store.search(embedding: embedding, scope: scope, limit: pool_limit, kinds: kinds)
41
+ pool = Engram::EmbeddingMetadata.search(
42
+ @store,
43
+ embedding: embedding,
44
+ embedding_metadata: embedding_metadata,
45
+ scope: scope,
46
+ limit: pool_limit,
47
+ kinds: kinds
48
+ )
41
49
 
42
50
  results = (reranking? ? rerank(pool, embedding) : pool).first(limit)
43
- touch(results) if @touch
51
+ touch(results, scope) if @touch
44
52
  payload[:result_count] = results.size
45
53
  payload[:candidate_count] = pool.size
46
54
  results
@@ -72,8 +80,8 @@ module Engram
72
80
  0.5**(age / @recency_halflife)
73
81
  end
74
82
 
75
- def touch(records)
76
- records.each { |record| @store.touch(id: record.id, at: Time.now) if record.id }
83
+ def touch(records, scope)
84
+ records.each { |record| @store.touch(scope: scope, id: record.id, at: Time.now) if record.id }
77
85
  end
78
86
  end
79
87
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Engram
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/engram.rb CHANGED
@@ -3,6 +3,9 @@
3
3
  require_relative "engram/version"
4
4
  require_relative "engram/configuration"
5
5
  require_relative "engram/math"
6
+ require_relative "engram/reserved_metadata"
7
+ require_relative "engram/embedding_metadata"
8
+ require_relative "engram/provenance"
6
9
  require_relative "engram/memory_kind"
7
10
  require_relative "engram/record"
8
11
  require_relative "engram/decision"
@@ -24,6 +27,7 @@ require_relative "engram/use_cases/recall"
24
27
  require_relative "engram/use_cases/inject"
25
28
  require_relative "engram/use_cases/observe"
26
29
  require_relative "engram/use_cases/forget"
30
+ require_relative "engram/use_cases/rebuild_embeddings"
27
31
 
28
32
  # Built-in adapters (pure Ruby, no external deps)
29
33
  require_relative "engram/adapters/in_memory_store"
@@ -51,6 +55,9 @@ require_relative "engram/integrations/ruby_llm"
51
55
  module Engram
52
56
  class Error < StandardError; end
53
57
 
58
+ # Raised when a live claim suppresses an observation that has not completed.
59
+ class ObservationInProgressError < Error; end
60
+
54
61
  class << self
55
62
  def config
56
63
  @config ||= Configuration.new
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: engram
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alexandr Kholodniak
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-06 00:00:00.000000000 Z
11
+ date: 2026-07-16 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: |
14
14
  Engram gives AI agents durable, long-term memory. It recalls relevant facts about a
@@ -36,6 +36,7 @@ files:
36
36
  - lib/engram/consolidators/heuristic_consolidator.rb
37
37
  - lib/engram/consolidators/llm_consolidator.rb
38
38
  - lib/engram/decision.rb
39
+ - lib/engram/embedding_metadata.rb
39
40
  - lib/engram/extractors/llm_extractor.rb
40
41
  - lib/engram/instrumentation.rb
41
42
  - lib/engram/integrations/ruby_llm.rb
@@ -50,15 +51,19 @@ files:
50
51
  - lib/engram/ports/extractor.rb
51
52
  - lib/engram/ports/memory_store.rb
52
53
  - lib/engram/ports/processed_turns.rb
54
+ - lib/engram/provenance.rb
53
55
  - lib/engram/rails/cache_processed_turns.rb
54
56
  - lib/engram/rails/has_memory.rb
55
57
  - lib/engram/rails/observe_job.rb
58
+ - lib/engram/rails/tasks.rake
56
59
  - lib/engram/railtie.rb
57
60
  - lib/engram/record.rb
61
+ - lib/engram/reserved_metadata.rb
58
62
  - lib/engram/turn_digest.rb
59
63
  - lib/engram/use_cases/forget.rb
60
64
  - lib/engram/use_cases/inject.rb
61
65
  - lib/engram/use_cases/observe.rb
66
+ - lib/engram/use_cases/rebuild_embeddings.rb
62
67
  - lib/engram/use_cases/recall.rb
63
68
  - lib/engram/version.rb
64
69
  - lib/generators/engram/install_generator.rb
@@ -89,7 +94,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
89
94
  - !ruby/object:Gem::Version
90
95
  version: '0'
91
96
  requirements: []
92
- rubygems_version: 3.5.11
97
+ rubygems_version: 3.5.22
93
98
  signing_key:
94
99
  specification_version: 4
95
100
  summary: Long-term memory for AI agents in Ruby — stored in your own Postgres.