engram 0.3.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 (42) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +38 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +202 -0
  5. data/lib/engram/adapters/fake_completion.rb +32 -0
  6. data/lib/engram/adapters/in_memory_processed_turns.rb +29 -0
  7. data/lib/engram/adapters/in_memory_store.rb +58 -0
  8. data/lib/engram/adapters/null_embedder.rb +28 -0
  9. data/lib/engram/adapters/pgvector_store.rb +90 -0
  10. data/lib/engram/adapters/ruby_llm_completion.rb +43 -0
  11. data/lib/engram/adapters/ruby_llm_embedder.rb +35 -0
  12. data/lib/engram/configuration.rb +28 -0
  13. data/lib/engram/consolidators/heuristic_consolidator.rb +31 -0
  14. data/lib/engram/consolidators/llm_consolidator.rb +98 -0
  15. data/lib/engram/decision.rb +27 -0
  16. data/lib/engram/extractors/llm_extractor.rb +85 -0
  17. data/lib/engram/integrations/ruby_llm.rb +40 -0
  18. data/lib/engram/math.rb +23 -0
  19. data/lib/engram/memory.rb +105 -0
  20. data/lib/engram/ports/completion.rb +15 -0
  21. data/lib/engram/ports/consolidator.rb +17 -0
  22. data/lib/engram/ports/embedder.rb +19 -0
  23. data/lib/engram/ports/extractor.rb +15 -0
  24. data/lib/engram/ports/memory_store.rb +41 -0
  25. data/lib/engram/ports/processed_turns.rb +20 -0
  26. data/lib/engram/rails/cache_processed_turns.rb +31 -0
  27. data/lib/engram/rails/has_memory.rb +32 -0
  28. data/lib/engram/rails/observe_job.rb +11 -0
  29. data/lib/engram/railtie.rb +23 -0
  30. data/lib/engram/record.rb +35 -0
  31. data/lib/engram/turn_digest.rb +28 -0
  32. data/lib/engram/use_cases/forget.rb +28 -0
  33. data/lib/engram/use_cases/inject.rb +22 -0
  34. data/lib/engram/use_cases/observe.rb +59 -0
  35. data/lib/engram/use_cases/recall.rb +69 -0
  36. data/lib/engram/version.rb +5 -0
  37. data/lib/engram.rb +66 -0
  38. data/lib/generators/engram/install_generator.rb +48 -0
  39. data/lib/generators/engram/templates/create_engram_memories.rb.tt +24 -0
  40. data/lib/generators/engram/templates/initializer.rb.tt +12 -0
  41. data/lib/generators/engram/templates/memory_record.rb.tt +9 -0
  42. metadata +91 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9afed525e71087af57cf1297cc80b4547abbe4df9e1077282ba0427cfeb5a708
4
+ data.tar.gz: 461907e0eafb4bed9442475a0bea0c2830cff62ac55291b7dc3eb9aeb8930b52
5
+ SHA512:
6
+ metadata.gz: d1fc61bad8a535990c93aa6f12401ffa4a18605357e1782b9e41be4a3d6ddbd9b4a00f805dba904c987db74a65773952cbfad806a60dfbe49a12e3b899cdbb16
7
+ data.tar.gz: 771b6d7030dd2ae664457c7eba01c7edd1f80f82af5a5566e57c93a97cf9bd1ab34077b638508789e40c7a3128645c0cfc7391fe5fd26d79dec22162696218f8
data/CHANGELOG.md ADDED
@@ -0,0 +1,38 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
+
6
+ ## [Unreleased]
7
+
8
+ ## [0.3.0] - 2026-05-25 — idempotency, smarter recall, forgetting
9
+
10
+ ### Added
11
+ - Idempotent observation: `ProcessedTurns` port, `InMemoryProcessedTurns`,
12
+ `Rails::CacheProcessedTurns`, and a stable `TurnDigest`. A repeated turn is skipped.
13
+ - Recall ranking options: `importance_weight`, `recency_weight`, and `recency_halflife`,
14
+ blended on top of vector similarity (defaults keep plain similarity search).
15
+ - `touch_on_recall` and `MemoryStore#touch` to update `last_accessed_at` on recall.
16
+ - `UseCases::Forget` and `Memory#forget_stale` to prune memories by age and importance.
17
+
18
+ ## [0.2.0] — extract → consolidate
19
+
20
+ ### Added
21
+ - `Completion` port for structured LLM calls; adapters `RubyLLMCompletion` and `FakeCompletion`.
22
+ - `Extractors::LLMExtractor` — derives durable, user-specific facts from a turn (schema + confidence threshold).
23
+ - `Consolidators::HeuristicConsolidator` (deterministic, dedup) and `Consolidators::LLMConsolidator`
24
+ (LLM-as-judge, batched ADD / UPDATE / FORGET / NOOP).
25
+ - `UseCases::Observe` orchestrator; `Memory#observe` / `Memory#observe_later`.
26
+ - `Decision` value object; `MemoryStore#update`/`#delete`; record ids.
27
+ - Rails `ObserveJob` for background observation.
28
+ - Consolidation dedup check in the eval harness.
29
+
30
+ ## [0.1.0] — recall + inject foundation
31
+
32
+ ### Added
33
+ - Ports-and-adapters core: `Record`, `MemoryStore`/`Embedder` ports, `Recall`/`Inject` use cases.
34
+ - Built-in adapters: `InMemoryStore`, `NullEmbedder` (zero-config, test-friendly).
35
+ - Optional adapters: `PgvectorStore` (neighbor), `RubyLLMEmbedder`.
36
+ - `Engram::Memory` facade and Rails `has_memory` macro.
37
+ - RubyLLM integration: `Engram.with_memory(chat, memory:)`.
38
+ - Install generator (migration + initializer + model).
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alexandr Kholodniak
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,202 @@
1
+ # Engram
2
+
3
+ Long-term memory for AI agents in Ruby — stored in **your own** Postgres.
4
+
5
+ Engram lets an agent remember a user across sessions. It recalls the facts relevant to the
6
+ current message and injects them into the prompt, so the model stops asking the same
7
+ questions twice. No external memory-as-a-service: your memories live in your database.
8
+
9
+ > Status: pre-1.0. Two things are implemented and tested: recall with prompt injection
10
+ > (v0.1), and extracting and consolidating memories from conversations (v0.2). The public
11
+ > API may still change before 1.0.
12
+
13
+ ## Why
14
+
15
+ LLMs are stateless. Every request starts from zero, so an assistant forgets that the user
16
+ is on the Pro plan, is vegetarian, or already tried clearing the cache. The usual fixes
17
+ fall short: stuffing whole transcripts into the prompt is expensive and noisy, and plain
18
+ RAG retrieves documents, not personal facts. Engram is the memory layer in between.
19
+
20
+ ## Before and after
21
+
22
+ Without a memory layer, every session starts blank:
23
+
24
+ ```text
25
+ Day 1
26
+ User: I'm on the Pro plan, and please keep answers short.
27
+ Agent: Got it.
28
+
29
+ Day 5 (new session — the model has forgotten)
30
+ User: Why am I being rate limited?
31
+ Agent: Which plan are you on? Can you share more about your setup?
32
+ ```
33
+
34
+ With engram, the facts from day 1 are recalled and added to the prompt before the model answers:
35
+
36
+ ```ruby
37
+ # Day 1: engram extracts and stores
38
+ # "User is on the Pro plan", "User prefers short answers"
39
+ current_user.memory.observe(conversation)
40
+
41
+ # Day 5: engram recalls the relevant facts, then asks the model
42
+ chat = Engram.with_memory(RubyLLM.chat, memory: current_user.memory)
43
+ chat.ask("Why am I being rate limited?")
44
+ ```
45
+
46
+ ```text
47
+ Agent: You're on the Pro plan, which has a per-minute request cap, and you're
48
+ hitting it. (Kept short, as you prefer.)
49
+ ```
50
+
51
+ ## Installation
52
+
53
+ ```ruby
54
+ # Gemfile
55
+ gem "engram"
56
+ ```
57
+
58
+ The core has **zero runtime dependencies**. Optional adapters need:
59
+
60
+ - `Engram::Adapters::PgvectorStore` → `neighbor` + ActiveRecord + Postgres/pgvector
61
+ - `Engram::Adapters::RubyLLMEmbedder` → `ruby_llm`
62
+
63
+ ## Quick start (plain Ruby)
64
+
65
+ ```ruby
66
+ require "engram"
67
+
68
+ memory = Engram::Memory.new(scope: "user:42") # zero-config: in-memory + null embedder
69
+
70
+ memory.add("Subscription tier is Pro")
71
+ memory.add("Prefers concise answers")
72
+
73
+ memory.recall("why am I being rate limited?")
74
+ # => [#<Engram::Record content="Subscription tier is Pro" ...>]
75
+ ```
76
+
77
+ ## Rails
78
+
79
+ ```bash
80
+ bin/rails generate engram:install # migration + initializer + model
81
+ bin/rails db:migrate
82
+ ```
83
+
84
+ ```ruby
85
+ class User < ApplicationRecord
86
+ has_memory # scope defaults to "user:<id>"
87
+ end
88
+
89
+ current_user.memory.add("Works at Acme Corp")
90
+ current_user.memory.recall("where does the user work?")
91
+ ```
92
+
93
+ ## RubyLLM integration
94
+
95
+ ```ruby
96
+ chat = Engram.with_memory(RubyLLM.chat, memory: current_user.memory)
97
+ chat.ask("why am I being rate limited?")
98
+ # recall + inject happen automatically before the model sees the message
99
+ ```
100
+
101
+ ## Automatic memory (v0.2)
102
+
103
+ Instead of adding facts by hand, let engram derive them from a conversation turn. It
104
+ extracts candidate facts, then consolidates them against what's already known —
105
+ add / update / forget / noop.
106
+
107
+ ```ruby
108
+ Engram.configure do |config|
109
+ config.completion = Engram::Adapters::RubyLLMCompletion.new
110
+ config.consolidator = :llm # or :heuristic for deterministic, no-LLM dedup
111
+ end
112
+
113
+ memory = current_user.memory
114
+ memory.observe([
115
+ {role: "user", content: "I switched from the Free plan to Pro"}
116
+ ])
117
+ # extracts "User is on the Pro plan", and if a "Free plan" memory exists, updates it
118
+ ```
119
+
120
+ In Rails, run it off the request path: `current_user.memory.observe_later(messages)`.
121
+
122
+ ## Tuning and maintenance (v0.3)
123
+
124
+ Observation is idempotent per turn: observing the same messages twice does nothing the
125
+ second time, so retries do not create duplicate memories or repeat LLM calls. In Rails,
126
+ use a persistent store so this also holds across job retries and processes:
127
+
128
+ ```ruby
129
+ Engram.configure do |c|
130
+ c.processed_turns = Engram::Rails::CacheProcessedTurns.new
131
+ end
132
+ ```
133
+
134
+ Recall is plain similarity search by default. You can blend in importance and recency:
135
+
136
+ ```ruby
137
+ Engram.configure do |c|
138
+ c.importance_weight = 0.3
139
+ c.recency_weight = 0.2
140
+ c.touch_on_recall = true # update last_accessed_at when a memory is recalled
141
+ end
142
+ ```
143
+
144
+ Prune memories you no longer need:
145
+
146
+ ```ruby
147
+ # Forget memories untouched for 90 days, but keep anything important
148
+ current_user.memory.forget_stale(older_than: 90 * 24 * 60 * 60, min_importance: 0.7)
149
+ ```
150
+
151
+ ## How it works
152
+
153
+ A loop around your LLM calls. Before a call: recall relevant memories and inject them.
154
+ After a turn (v0.2): extract new facts, consolidate them, and persist. The store
155
+ (Postgres + pgvector) is the only thing that persists between sessions.
156
+
157
+ ## Architecture
158
+
159
+ Ports-and-adapters. A pure-Ruby core depends on `MemoryStore` and `Embedder` ports;
160
+ pgvector, RubyLLM, and Rails are swappable adapters. This keeps the domain fast to test
161
+ (in-memory + null adapters, no DB or API keys) and lets the v0.2 `Extractor`/`Consolidator`
162
+ slot in without rework.
163
+
164
+ ## Development
165
+
166
+ ```bash
167
+ bundle install
168
+ bundle exec rspec # unit suite (no DB, no network)
169
+ bundle exec standardrb # lint
170
+ bundle exec rake eval # recall quality harness (precision@k)
171
+ ```
172
+
173
+ Integration tests exercise the real Postgres + pgvector adapter (tagged `:integration`,
174
+ skipped by default):
175
+
176
+ ```bash
177
+ DATABASE_URL=postgres://postgres:postgres@localhost:5432/engram_test \
178
+ bundle exec rspec --tag integration
179
+ ```
180
+
181
+ For honest recall numbers, run the eval with a real embedder instead of the test stub.
182
+ `ruby_llm` is not a dependency, so install it separately first:
183
+
184
+ ```bash
185
+ gem install ruby_llm
186
+ ENGRAM_EMBEDDER=ruby_llm OPENAI_API_KEY=... ruby eval/run.rb
187
+ ```
188
+
189
+ On the bundled fixture set, recall@3 is 100% (4/4) with OpenAI's text-embedding-3-small,
190
+ and the consolidation dedup checks pass. The fixture is deliberately small. Treat it as a
191
+ retrieval smoke test, not a benchmark.
192
+
193
+ ## Roadmap
194
+
195
+ - v0.1 (done): recall + inject foundation, adapters, Rails + RubyLLM integration.
196
+ - v0.2 (done): extract and consolidate (ADD / UPDATE / FORGET), background jobs.
197
+ - v0.3 (done): idempotent observation, importance/recency recall, forgetting and decay.
198
+ - later: memory types per policy, additional storage backends, larger eval benchmarks.
199
+
200
+ ## License
201
+
202
+ MIT. See [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ module Adapters
5
+ # Deterministic Completion for tests. Returns queued responses (already-parsed Hashes)
6
+ # in order, and records every call so specs can assert on the prompts/schemas sent.
7
+ class FakeCompletion
8
+ include Ports::Completion
9
+
10
+ attr_reader :calls
11
+
12
+ def initialize(responses: [])
13
+ @responses = responses.dup
14
+ @calls = []
15
+ end
16
+
17
+ def enqueue(response)
18
+ @responses << response
19
+ self
20
+ end
21
+
22
+ def complete(system:, user:, schema:)
23
+ @calls << {system: system, user: user, schema: schema}
24
+ if @responses.empty?
25
+ raise Engram::Error, "FakeCompletion: no scripted response left (call ##{@calls.size})"
26
+ end
27
+
28
+ @responses.shift
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ module Adapters
5
+ # In-process ProcessedTurns. The zero-config default; guards against double-processing
6
+ # within a single process. For cross-process/retry durability in Rails, use a persistent
7
+ # adapter such as Rails::CacheProcessedTurns.
8
+ class InMemoryProcessedTurns
9
+ include Ports::ProcessedTurns
10
+
11
+ def initialize
12
+ @keys = Set.new
13
+ end
14
+
15
+ def seen?(key)
16
+ @keys.include?(key)
17
+ end
18
+
19
+ def record(key)
20
+ @keys << key
21
+ key
22
+ end
23
+
24
+ def clear
25
+ @keys.clear
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ module Adapters
5
+ # In-process MemoryStore. Used as the zero-config default and in unit tests.
6
+ # Search is exact cosine similarity over the stored vectors.
7
+ class InMemoryStore
8
+ include Ports::MemoryStore
9
+
10
+ def initialize
11
+ @records = {}
12
+ @sequence = 0
13
+ end
14
+
15
+ def add(record)
16
+ record.id ||= (@sequence += 1)
17
+ @records[record.id] = record
18
+ record
19
+ end
20
+
21
+ def search(embedding:, scope:, limit:)
22
+ @records
23
+ .values
24
+ .select { |r| r.scope == scope && r.embedding }
25
+ .map { |r| [r, Engram::Math.cosine_similarity(embedding, r.embedding)] }
26
+ .sort_by { |(_, score)| -score }
27
+ .first(limit)
28
+ .map { |(record, _)| record }
29
+ end
30
+
31
+ def all(scope:)
32
+ @records.values.select { |r| r.scope == scope }
33
+ end
34
+
35
+ def update(id:, record:)
36
+ raise Engram::Error, "no memory with id #{id.inspect}" unless @records.key?(id)
37
+
38
+ record.id = id
39
+ @records[id] = record
40
+ end
41
+
42
+ def delete(id:)
43
+ @records.delete(id)
44
+ end
45
+
46
+ def touch(id:, at: Time.now)
47
+ record = @records[id]
48
+ record.last_accessed_at = at if record
49
+ record
50
+ end
51
+
52
+ def clear
53
+ @records.clear
54
+ @sequence = 0
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Engram
6
+ module Adapters
7
+ # Deterministic, network-free embedder for tests and the zero-config default.
8
+ # NOT semantic — equal text yields equal vectors, but unrelated text is not
9
+ # meaningfully close. Good enough to exercise the pipeline; useless for quality.
10
+ class NullEmbedder
11
+ include Ports::Embedder
12
+
13
+ def initialize(dimensions: 16)
14
+ @dimensions = dimensions
15
+ end
16
+
17
+ attr_reader :dimensions
18
+
19
+ def embed(text)
20
+ seed = Digest::SHA256.hexdigest(text.to_s)
21
+ Array.new(@dimensions) do |i|
22
+ byte = seed[(i * 2) % seed.length, 2].to_i(16)
23
+ (byte / 255.0) * 2 - 1 # map 0..255 -> -1.0..1.0
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ module Adapters
5
+ # MemoryStore backed by PostgreSQL + pgvector via the `neighbor` gem.
6
+ #
7
+ # Requires the host app to provide ActiveRecord, the `neighbor` gem, and an AR model
8
+ # (default: Engram::MemoryRecord) created by the install generator. These are NOT hard
9
+ # dependencies of engram; this adapter only references them at call time.
10
+ class PgvectorStore
11
+ include Ports::MemoryStore
12
+
13
+ def initialize(model: nil)
14
+ @model = model
15
+ end
16
+
17
+ def add(record)
18
+ row = model.create!(
19
+ content: record.content,
20
+ scope: record.scope,
21
+ kind: record.kind.to_s,
22
+ importance: record.importance,
23
+ metadata: record.metadata,
24
+ embedding: record.embedding
25
+ )
26
+ to_record(row)
27
+ end
28
+
29
+ def search(embedding:, scope:, limit:)
30
+ model
31
+ .where(scope: scope)
32
+ .nearest_neighbors(:embedding, embedding, distance: "cosine")
33
+ .limit(limit)
34
+ .map { |row| to_record(row) }
35
+ end
36
+
37
+ def all(scope:)
38
+ model.where(scope: scope).map { |row| to_record(row) }
39
+ end
40
+
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)
51
+ end
52
+
53
+ def delete(id:)
54
+ model.where(id: id).delete_all
55
+ end
56
+
57
+ def touch(id:, at: Time.now)
58
+ model.where(id: id).update_all(last_accessed_at: at)
59
+ end
60
+
61
+ private
62
+
63
+ def model
64
+ @model ||= resolve_default_model
65
+ end
66
+
67
+ def resolve_default_model
68
+ unless defined?(Engram::MemoryRecord)
69
+ raise Engram::Error,
70
+ "PgvectorStore needs an ActiveRecord model. Run the install generator or pass `model:`."
71
+ end
72
+ Engram::MemoryRecord
73
+ end
74
+
75
+ def to_record(row)
76
+ Engram::Record.new(
77
+ id: row.id,
78
+ content: row.content,
79
+ scope: row.scope,
80
+ embedding: row.embedding,
81
+ kind: (row.kind || :semantic).to_sym,
82
+ importance: row.importance || 1.0,
83
+ metadata: row.metadata || {},
84
+ created_at: row.created_at,
85
+ last_accessed_at: row.try(:last_accessed_at)
86
+ )
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ module Adapters
5
+ # Completion backed by RubyLLM structured output. Requires the host app to add the
6
+ # `ruby_llm` gem and configure credentials. Referenced only at call time.
7
+ #
8
+ # NOTE: exercised via integration tests, not the unit suite (which uses FakeCompletion).
9
+ class RubyLLMCompletion
10
+ include Ports::Completion
11
+
12
+ def initialize(model: nil)
13
+ @model = model
14
+ end
15
+
16
+ def complete(system:, user:, schema:)
17
+ ensure_ruby_llm!
18
+ chat = @model ? RubyLLM.chat(model: @model) : RubyLLM.chat
19
+ chat.with_instructions(system) if system
20
+ response = chat.with_schema(schema).ask(user)
21
+ coerce(response.content)
22
+ end
23
+
24
+ private
25
+
26
+ def coerce(content)
27
+ return content if content.is_a?(Hash)
28
+
29
+ require "json"
30
+ JSON.parse(content)
31
+ rescue JSON::ParserError => e
32
+ raise Engram::Error, "RubyLLMCompletion expected structured output: #{e.message}"
33
+ end
34
+
35
+ def ensure_ruby_llm!
36
+ return if defined?(RubyLLM)
37
+
38
+ raise Engram::Error,
39
+ "RubyLLMCompletion requires the `ruby_llm` gem. Add it to your Gemfile and configure it."
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ module Adapters
5
+ # Embedder backed by RubyLLM. Requires the host app to add the `ruby_llm` gem and
6
+ # configure its credentials. Referenced only at call time, so engram loads without it.
7
+ class RubyLLMEmbedder
8
+ include Ports::Embedder
9
+
10
+ DEFAULT_MODEL = "text-embedding-3-small"
11
+ DEFAULT_DIMENSIONS = 1536
12
+
13
+ def initialize(model: DEFAULT_MODEL, dimensions: DEFAULT_DIMENSIONS)
14
+ @model = model
15
+ @dimensions = dimensions
16
+ end
17
+
18
+ attr_reader :dimensions
19
+
20
+ def embed(text)
21
+ ensure_ruby_llm!
22
+ RubyLLM.embed(text, model: @model).vectors
23
+ end
24
+
25
+ private
26
+
27
+ def ensure_ruby_llm!
28
+ return if defined?(RubyLLM)
29
+
30
+ raise Engram::Error,
31
+ "RubyLLMEmbedder requires the `ruby_llm` gem. Add it to your Gemfile and configure it."
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ # Holds the wired adapters and defaults. Out of the box everything works in memory,
5
+ # so the gem is usable (and testable) with zero infrastructure. In a Rails app the
6
+ # initializer typically swaps in PgvectorStore + RubyLLMEmbedder + RubyLLMCompletion.
7
+ class Configuration
8
+ attr_accessor :store, :embedder, :completion, :default_limit,
9
+ :consolidator, :extraction_min_confidence, :processed_turns,
10
+ :importance_weight, :recency_weight, :recency_halflife, :touch_on_recall
11
+
12
+ def initialize
13
+ @store = Adapters::InMemoryStore.new
14
+ @embedder = Adapters::NullEmbedder.new
15
+ @completion = nil # required for observe (extract/consolidate); nil until configured
16
+ @default_limit = 5
17
+ @consolidator = :heuristic # :heuristic (deterministic) or :llm (LLM-as-judge)
18
+ @extraction_min_confidence = 0.5
19
+ @processed_turns = Adapters::InMemoryProcessedTurns.new # idempotency for observe
20
+
21
+ # Recall ranking. With both weights at 0.0, recall is plain similarity search.
22
+ @importance_weight = 0.0
23
+ @recency_weight = 0.0
24
+ @recency_halflife = UseCases::Recall::DEFAULT_HALFLIFE
25
+ @touch_on_recall = false
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ module Consolidators
5
+ # Deterministic, no-LLM consolidation. ADDs a candidate unless a near-duplicate
6
+ # already exists (then NOOP). It cannot detect contradictions or updates — that is the
7
+ # LLMConsolidator's job. Useful as the default in tests and as a zero-cost fallback.
8
+ class HeuristicConsolidator
9
+ include Ports::Consolidator
10
+
11
+ def initialize(store:, similarity_threshold: 0.97)
12
+ @store = store
13
+ @similarity_threshold = similarity_threshold
14
+ end
15
+
16
+ def reconcile_all(candidates:, scope:)
17
+ Array(candidates).map do |candidate|
18
+ nearest = @store.search(embedding: candidate.embedding, scope: scope, limit: 1).first
19
+ similarity = nearest ? Engram::Math.cosine_similarity(candidate.embedding, nearest.embedding) : 0.0
20
+
21
+ if nearest && similarity >= @similarity_threshold
22
+ Engram::Decision.new(action: :noop, candidate: candidate,
23
+ reason: "near-duplicate (sim=#{similarity.round(3)})")
24
+ else
25
+ Engram::Decision.new(action: :add, candidate: candidate)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end