engram 0.4.0 → 0.6.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 +113 -0
- data/README.md +217 -11
- data/lib/engram/adapters/in_memory_processed_turns.rb +40 -8
- data/lib/engram/adapters/in_memory_store.rb +30 -11
- data/lib/engram/adapters/null_embedder.rb +9 -0
- data/lib/engram/adapters/pgvector_store.rb +50 -17
- data/lib/engram/adapters/ruby_llm_embedder.rb +45 -4
- data/lib/engram/consolidators/heuristic_consolidator.rb +7 -1
- data/lib/engram/consolidators/llm_consolidator.rb +37 -10
- data/lib/engram/embedding_metadata.rb +135 -0
- data/lib/engram/extraction.rb +30 -0
- data/lib/engram/extractors/llm_extractor.rb +4 -3
- data/lib/engram/internal/candidate_integrity.rb +510 -0
- data/lib/engram/internal/core_hash.rb +36 -0
- data/lib/engram/internal/scope.rb +31 -0
- data/lib/engram/memory.rb +28 -1
- data/lib/engram/persistence.rb +83 -8
- data/lib/engram/persistence_policy.rb +11 -1
- data/lib/engram/ports/consolidator.rb +7 -2
- data/lib/engram/ports/extractor.rb +1 -1
- data/lib/engram/ports/memory_store.rb +25 -7
- data/lib/engram/ports/processed_turns.rb +16 -8
- data/lib/engram/provenance.rb +588 -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 +12 -5
- data/lib/engram/reserved_metadata.rb +52 -0
- data/lib/engram/use_cases/forget.rb +6 -2
- data/lib/engram/use_cases/grounding_report.rb +44 -0
- data/lib/engram/use_cases/observe.rb +300 -26
- data/lib/engram/use_cases/rebuild_embeddings.rb +189 -0
- data/lib/engram/use_cases/recall.rb +12 -4
- data/lib/engram/use_cases/source_impact.rb +42 -0
- data/lib/engram/version.rb +1 -1
- data/lib/engram.rb +13 -0
- metadata +14 -3
|
@@ -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 =
|
|
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
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Engram
|
|
4
|
+
module UseCases
|
|
5
|
+
# Look up which memories in a scope reference a given host source.
|
|
6
|
+
#
|
|
7
|
+
# Returns the records whose understood provenance lists a source matching both
|
|
8
|
+
# `source_id` and `source_type` exactly, with no trimming or normalization.
|
|
9
|
+
# Reads stay tolerant: legacy, malformed, and future-schema
|
|
10
|
+
# provenance simply do not match. Source IDs are references, not authorization
|
|
11
|
+
# boundaries, so callers still bound the lookup to a `scope`.
|
|
12
|
+
class SourceImpact
|
|
13
|
+
def initialize(store:)
|
|
14
|
+
@store = store
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Returns the Array<Record> in `scope` referencing the exact source. Result
|
|
18
|
+
# order follows the store's #all enumeration and is not otherwise guaranteed.
|
|
19
|
+
def call(scope:, source_id:, source_type:)
|
|
20
|
+
source_id = require_string!("source_id", source_id)
|
|
21
|
+
source_type = require_string!("source_type", source_type)
|
|
22
|
+
|
|
23
|
+
@store.all(scope: scope).select do |record|
|
|
24
|
+
provenance = record.provenance
|
|
25
|
+
provenance&.sources&.any? do |source|
|
|
26
|
+
source.source_id == source_id && source.source_type == source_type
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def require_string!(name, value)
|
|
34
|
+
unless value.is_a?(String) && !value.strip.empty?
|
|
35
|
+
raise ArgumentError, "#{name} must be a non-empty String"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
value
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/engram/version.rb
CHANGED
data/lib/engram.rb
CHANGED
|
@@ -3,8 +3,15 @@
|
|
|
3
3
|
require_relative "engram/version"
|
|
4
4
|
require_relative "engram/configuration"
|
|
5
5
|
require_relative "engram/math"
|
|
6
|
+
require_relative "engram/internal/core_hash"
|
|
7
|
+
require_relative "engram/reserved_metadata"
|
|
8
|
+
require_relative "engram/embedding_metadata"
|
|
9
|
+
require_relative "engram/provenance"
|
|
6
10
|
require_relative "engram/memory_kind"
|
|
7
11
|
require_relative "engram/record"
|
|
12
|
+
require_relative "engram/internal/scope"
|
|
13
|
+
require_relative "engram/internal/candidate_integrity"
|
|
14
|
+
require_relative "engram/extraction"
|
|
8
15
|
require_relative "engram/decision"
|
|
9
16
|
require_relative "engram/turn_digest"
|
|
10
17
|
require_relative "engram/persistence_policy"
|
|
@@ -24,6 +31,9 @@ require_relative "engram/use_cases/recall"
|
|
|
24
31
|
require_relative "engram/use_cases/inject"
|
|
25
32
|
require_relative "engram/use_cases/observe"
|
|
26
33
|
require_relative "engram/use_cases/forget"
|
|
34
|
+
require_relative "engram/use_cases/rebuild_embeddings"
|
|
35
|
+
require_relative "engram/use_cases/source_impact"
|
|
36
|
+
require_relative "engram/use_cases/grounding_report"
|
|
27
37
|
|
|
28
38
|
# Built-in adapters (pure Ruby, no external deps)
|
|
29
39
|
require_relative "engram/adapters/in_memory_store"
|
|
@@ -51,6 +61,9 @@ require_relative "engram/integrations/ruby_llm"
|
|
|
51
61
|
module Engram
|
|
52
62
|
class Error < StandardError; end
|
|
53
63
|
|
|
64
|
+
# Raised when a live claim suppresses an observation that has not completed.
|
|
65
|
+
class ObservationInProgressError < Error; end
|
|
66
|
+
|
|
54
67
|
class << self
|
|
55
68
|
def config
|
|
56
69
|
@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
|
+
version: 0.6.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-
|
|
11
|
+
date: 2026-07-28 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,9 +36,14 @@ 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
|
|
40
|
+
- lib/engram/extraction.rb
|
|
39
41
|
- lib/engram/extractors/llm_extractor.rb
|
|
40
42
|
- lib/engram/instrumentation.rb
|
|
41
43
|
- lib/engram/integrations/ruby_llm.rb
|
|
44
|
+
- lib/engram/internal/candidate_integrity.rb
|
|
45
|
+
- lib/engram/internal/core_hash.rb
|
|
46
|
+
- lib/engram/internal/scope.rb
|
|
42
47
|
- lib/engram/math.rb
|
|
43
48
|
- lib/engram/memory.rb
|
|
44
49
|
- lib/engram/memory_kind.rb
|
|
@@ -50,16 +55,22 @@ files:
|
|
|
50
55
|
- lib/engram/ports/extractor.rb
|
|
51
56
|
- lib/engram/ports/memory_store.rb
|
|
52
57
|
- lib/engram/ports/processed_turns.rb
|
|
58
|
+
- lib/engram/provenance.rb
|
|
53
59
|
- lib/engram/rails/cache_processed_turns.rb
|
|
54
60
|
- lib/engram/rails/has_memory.rb
|
|
55
61
|
- lib/engram/rails/observe_job.rb
|
|
62
|
+
- lib/engram/rails/tasks.rake
|
|
56
63
|
- lib/engram/railtie.rb
|
|
57
64
|
- lib/engram/record.rb
|
|
65
|
+
- lib/engram/reserved_metadata.rb
|
|
58
66
|
- lib/engram/turn_digest.rb
|
|
59
67
|
- lib/engram/use_cases/forget.rb
|
|
68
|
+
- lib/engram/use_cases/grounding_report.rb
|
|
60
69
|
- lib/engram/use_cases/inject.rb
|
|
61
70
|
- lib/engram/use_cases/observe.rb
|
|
71
|
+
- lib/engram/use_cases/rebuild_embeddings.rb
|
|
62
72
|
- lib/engram/use_cases/recall.rb
|
|
73
|
+
- lib/engram/use_cases/source_impact.rb
|
|
63
74
|
- lib/engram/version.rb
|
|
64
75
|
- lib/generators/engram/install_generator.rb
|
|
65
76
|
- lib/generators/engram/templates/create_engram_memories.rb.tt
|
|
@@ -89,7 +100,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
89
100
|
- !ruby/object:Gem::Version
|
|
90
101
|
version: '0'
|
|
91
102
|
requirements: []
|
|
92
|
-
rubygems_version: 3.5.
|
|
103
|
+
rubygems_version: 3.5.22
|
|
93
104
|
signing_key:
|
|
94
105
|
specification_version: 4
|
|
95
106
|
summary: Long-term memory for AI agents in Ruby — stored in your own Postgres.
|