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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +38 -0
- data/LICENSE.txt +21 -0
- data/README.md +202 -0
- data/lib/engram/adapters/fake_completion.rb +32 -0
- data/lib/engram/adapters/in_memory_processed_turns.rb +29 -0
- data/lib/engram/adapters/in_memory_store.rb +58 -0
- data/lib/engram/adapters/null_embedder.rb +28 -0
- data/lib/engram/adapters/pgvector_store.rb +90 -0
- data/lib/engram/adapters/ruby_llm_completion.rb +43 -0
- data/lib/engram/adapters/ruby_llm_embedder.rb +35 -0
- data/lib/engram/configuration.rb +28 -0
- data/lib/engram/consolidators/heuristic_consolidator.rb +31 -0
- data/lib/engram/consolidators/llm_consolidator.rb +98 -0
- data/lib/engram/decision.rb +27 -0
- data/lib/engram/extractors/llm_extractor.rb +85 -0
- data/lib/engram/integrations/ruby_llm.rb +40 -0
- data/lib/engram/math.rb +23 -0
- data/lib/engram/memory.rb +105 -0
- data/lib/engram/ports/completion.rb +15 -0
- data/lib/engram/ports/consolidator.rb +17 -0
- data/lib/engram/ports/embedder.rb +19 -0
- data/lib/engram/ports/extractor.rb +15 -0
- data/lib/engram/ports/memory_store.rb +41 -0
- data/lib/engram/ports/processed_turns.rb +20 -0
- data/lib/engram/rails/cache_processed_turns.rb +31 -0
- data/lib/engram/rails/has_memory.rb +32 -0
- data/lib/engram/rails/observe_job.rb +11 -0
- data/lib/engram/railtie.rb +23 -0
- data/lib/engram/record.rb +35 -0
- data/lib/engram/turn_digest.rb +28 -0
- data/lib/engram/use_cases/forget.rb +28 -0
- data/lib/engram/use_cases/inject.rb +22 -0
- data/lib/engram/use_cases/observe.rb +59 -0
- data/lib/engram/use_cases/recall.rb +69 -0
- data/lib/engram/version.rb +5 -0
- data/lib/engram.rb +66 -0
- data/lib/generators/engram/install_generator.rb +48 -0
- data/lib/generators/engram/templates/create_engram_memories.rb.tt +24 -0
- data/lib/generators/engram/templates/initializer.rb.tt +12 -0
- data/lib/generators/engram/templates/memory_record.rb.tt +9 -0
- 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
|