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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +57 -0
- data/README.md +95 -9
- data/lib/engram/adapters/in_memory_processed_turns.rb +40 -8
- data/lib/engram/adapters/in_memory_store.rb +25 -10
- data/lib/engram/adapters/null_embedder.rb +9 -0
- data/lib/engram/adapters/pgvector_store.rb +35 -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/extractors/llm_extractor.rb +4 -3
- data/lib/engram/memory.rb +13 -1
- data/lib/engram/persistence.rb +10 -5
- data/lib/engram/ports/memory_store.rb +14 -7
- data/lib/engram/ports/processed_turns.rb +16 -8
- data/lib/engram/provenance.rb +257 -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/reserved_metadata.rb +52 -0
- data/lib/engram/use_cases/forget.rb +6 -2
- data/lib/engram/use_cases/observe.rb +43 -22
- data/lib/engram/use_cases/rebuild_embeddings.rb +189 -0
- data/lib/engram/use_cases/recall.rb +12 -4
- data/lib/engram/version.rb +1 -1
- data/lib/engram.rb +7 -0
- metadata +8 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8a38dfd456e4e89f37a9d4d8949a5bb77b9736d95621e9172ba1db72fb3a5771
|
|
4
|
+
data.tar.gz: 0b537ee13bea0313fd770c529024af45e9ace8fb84f7e49621c2dc7b9d6b888c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 768d5d18a9ce04f60db58957127df76fbdc30482df4976d7d04b082af005850ba821ba45a35417c33946668af60331e869cf930e6239409ba10168a453f58665
|
|
7
|
+
data.tar.gz: 7ec7997ba40947deede93b6e970a549bf6caae32eed79f0e4c3922ebecbf05bc1545b5565afc521c9dab6c0f95b27898b3ae9bd9f3f57d9935a48d18e9211322
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,63 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.5.0] - 2026-07-17
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Embedding provenance metadata is stored with new memories so applications can detect model
|
|
12
|
+
and dimension drift during store search result validation.
|
|
13
|
+
- `Memory#rebuild_embeddings` and `Engram::UseCases::RebuildEmbeddings` for scoped,
|
|
14
|
+
deterministic rebuilding of stale vectors plus provenance metadata.
|
|
15
|
+
- Added a focused rake task `engram:rebuild_embeddings` with batch control and optional
|
|
16
|
+
forced full-scope rebuild mode for recovery after provider/model changes. The task is
|
|
17
|
+
packaged with the gem and loaded into host Rails apps by the Railtie, where it depends on
|
|
18
|
+
`:environment` so app initializers run first. `STALE_ONLY` accepts `false`, `0`, or `no`.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- `MemoryStore` mutations now require an explicit `scope:` and enforce the `(scope, id)` boundary
|
|
22
|
+
for update, delete, and touch operations. Custom stores must adopt the new scoped signatures;
|
|
23
|
+
`update` returns the updated record or raises `Engram::Error`, while delete and touch return an
|
|
24
|
+
affected-row count.
|
|
25
|
+
- **Breaking (pre-1.0):** the `ProcessedTurns` port migrated from the previous check/mark API
|
|
26
|
+
to `claim`, `complete`, `release`, and `completed?`; custom adapters must implement the new
|
|
27
|
+
claim lifecycle. A successful claim returns a truthy opaque token.
|
|
28
|
+
- Observation idempotency now uses atomic, scope-aware claims with an in-progress lease and
|
|
29
|
+
completed state. `InMemoryProcessedTurns` is thread-safe and releases failures immediately.
|
|
30
|
+
Generic cache release is a no-op until lease expiry because ActiveSupport cache has no atomic
|
|
31
|
+
compare-and-delete; completion never deletes a possibly newer claim. Completed markers
|
|
32
|
+
suppress later calls only for their configured `ttl`. A failed completion write leaves the
|
|
33
|
+
lease to suppress work until expiry, after which already-applied work may replay. `lease_ttl`
|
|
34
|
+
should cover the longest observation while remaining much shorter than `ttl`, and
|
|
35
|
+
`Rails::CacheProcessedTurns` uses atomic `unless_exist` writes with separate claim and
|
|
36
|
+
completion keys. This coordinates concurrent work but does not make multi-decision memory
|
|
37
|
+
persistence and claim completion a crash-proof transaction. Lease expiry may permit overlap,
|
|
38
|
+
so claims do not guarantee ownership, fencing, or exactly-once execution.
|
|
39
|
+
- Added API stability and migration posture documentation for pre-1.0 freeze planning, including public surface boundaries and legacy compatibility points.
|
|
40
|
+
- Store search result validation now raises a clear `Engram::Error` when stored embedding
|
|
41
|
+
metadata or vector dimensions conflict with the active embedder, while legacy records without
|
|
42
|
+
metadata remain searchable when their vector dimensions match the active embedder.
|
|
43
|
+
- Caller metadata keys named `_engram` are now reserved for Engram-owned embedding provenance;
|
|
44
|
+
rename any application metadata stored under that key before adding new memories.
|
|
45
|
+
- `RubyLLMEmbedder` now requests explicitly configured `dimensions:` from the provider (on
|
|
46
|
+
RubyLLM versions that support the option) and validates every returned vector against the
|
|
47
|
+
configured dimensions, raising a clear `Engram::Error` on mismatch. Previously the option
|
|
48
|
+
was recorded in metadata but never sent or checked, so a mismatch surfaced later as opaque
|
|
49
|
+
pgvector insert failures. If you use a model whose native vector size differs from 1536,
|
|
50
|
+
set `dimensions:` to that model's actual output size.
|
|
51
|
+
|
|
52
|
+
### Fixed
|
|
53
|
+
- `Observe` now raises `Engram::ObservationInProgressError` when a turn is claimed but not
|
|
54
|
+
completed, rather than reporting a successful no-op. `ObserveJob` retries this error with
|
|
55
|
+
backoff that outlasts the default claim lease; direct callers should handle it as retryable.
|
|
56
|
+
- Stale detection in `rebuild_embeddings` now compares against the embedder's declared
|
|
57
|
+
dimensions instead of the stored record's vector length, so a dimensions-only embedder
|
|
58
|
+
change marks existing rows stale. Previously the default stale-only rebuild silently
|
|
59
|
+
skipped every row after such a change. Embedders that do not declare dimensions keep the
|
|
60
|
+
previous record-length comparison.
|
|
61
|
+
- `LLMConsolidator` now ignores malformed decisions and rejects `update`/`forget` targets
|
|
62
|
+
that were not shown to the model, preventing invalid model output from partially applying
|
|
63
|
+
a turn. Cross-scope updates from custom consolidators still raise in `Observe`.
|
|
64
|
+
|
|
8
65
|
## [0.4.0] - 2026-06-06
|
|
9
66
|
|
|
10
67
|
### Added
|
data/README.md
CHANGED
|
@@ -61,6 +61,41 @@ chat.ask("Why am I being rate limited?")
|
|
|
61
61
|
- Persistence policy that rejects obvious secrets and transient task-progress updates before storage.
|
|
62
62
|
- Idempotent observation, recency/importance-aware ranking, recall touching, and stale-memory pruning.
|
|
63
63
|
|
|
64
|
+
|
|
65
|
+
## API stability and migration posture
|
|
66
|
+
|
|
67
|
+
Engram is intentionally marked pre-1.0; default behavior is to prioritize safety and compatibility, while still allowing occasional focused API additions.
|
|
68
|
+
|
|
69
|
+
### Current public surface
|
|
70
|
+
|
|
71
|
+
The following are part of the documented public surface and should remain stable except where
|
|
72
|
+
explicitly version-gated:
|
|
73
|
+
|
|
74
|
+
- Core types and facade: `Engram::Memory`, `Engram::Record`, `Engram::Decision`, `Engram::PersistencePolicy`, and `Engram.with_memory`.
|
|
75
|
+
- Store and adapter ports: `Engram::Ports::MemoryStore`, `Engram::Ports::Embedder`, `Engram::Ports::Completion`.
|
|
76
|
+
- Rails integration points: `has_memory`, `Memory#observe_later`, and generator outputs under `engram:` rake tasks.
|
|
77
|
+
- Lifecycle methods in `Engram::Memory`: `add`, `recall`, `inject_into`, `observe`, `observe_later`, `forget_stale`, and `rebuild_embeddings`.
|
|
78
|
+
- RubyLLM adapter contract points and evaluator entrypoints (`rake eval`, `rake eval:real`).
|
|
79
|
+
|
|
80
|
+
### Backward-compatibility commitments (pre-1.0)
|
|
81
|
+
|
|
82
|
+
- Compatibility adjustments should be additive where possible.
|
|
83
|
+
- Behavioral changes that could break callers should be documented under `CHANGELOG.md` and tested with migration scenarios.
|
|
84
|
+
- Legacy compatibility notes already in effect:
|
|
85
|
+
- `kind: "semantic"` is normalized to `:fact` for read paths.
|
|
86
|
+
- Existing rows created without embedding provenance are still readable when dimensions match the active embedder.
|
|
87
|
+
|
|
88
|
+
### 1.0 pre-freeze checklist
|
|
89
|
+
|
|
90
|
+
Before the 1.0 release marker, freeze the public API by:
|
|
91
|
+
|
|
92
|
+
1. Finalizing configuration and initializer keys in docs and examples.
|
|
93
|
+
2. Writing upgrade notes for any remaining behavior-shifting defaults.
|
|
94
|
+
3. Verifying migrations/rebuild flows for legacy rows remain observable and recoverable.
|
|
95
|
+
4. Keeping security and persistence-policy boundaries explicit in host-app guidance.
|
|
96
|
+
|
|
97
|
+
Use `CHANGELOG.md` as the authoritative source for breaking/compatibility changes while still in pre-1.0.
|
|
98
|
+
|
|
64
99
|
## Installation
|
|
65
100
|
|
|
66
101
|
```ruby
|
|
@@ -112,15 +147,23 @@ current_user.memory.observe_later([
|
|
|
112
147
|
```
|
|
113
148
|
|
|
114
149
|
`observe_later` uses ActiveJob, so configure the queue adapter you already use in
|
|
115
|
-
production (Sidekiq, Solid Queue, GoodJob, etc.).
|
|
116
|
-
processes, use the Rails cache-backed processed-turn store
|
|
150
|
+
production (Sidekiq, Solid Queue, GoodJob, etc.). To coordinate observation claims across
|
|
151
|
+
retries and processes, use the Rails cache-backed processed-turn store with a shared cache
|
|
152
|
+
whose `write(..., unless_exist: true)` operation is atomic (for example Redis or Solid Cache):
|
|
117
153
|
|
|
118
154
|
```ruby
|
|
119
155
|
Engram.configure do |config|
|
|
120
|
-
config.processed_turns = Engram::Rails::CacheProcessedTurns.new
|
|
156
|
+
config.processed_turns = Engram::Rails::CacheProcessedTurns.new(lease_ttl: 5.minutes)
|
|
121
157
|
end
|
|
122
158
|
```
|
|
123
159
|
|
|
160
|
+
The adapter rejects backends that do not return a boolean result for `unless_exist`, but it
|
|
161
|
+
cannot detect a backend that accepts the option without implementing it atomically. Rails'
|
|
162
|
+
memory cache coordinates threads in one process only, and `NullStore` is not suitable.
|
|
163
|
+
Completed suppression is bounded by `ttl` (24 hours by default). Set `lease_ttl` longer than
|
|
164
|
+
the longest expected observation, but normally much shorter than `ttl`, so crashed work can
|
|
165
|
+
be retried promptly without weakening the completed-turn suppression window.
|
|
166
|
+
|
|
124
167
|
## Postgres + pgvector setup
|
|
125
168
|
|
|
126
169
|
The Rails generator creates an `engram_memories` table with a `vector` extension and a
|
|
@@ -153,7 +196,9 @@ If you change embedding models, keep the database column dimension in sync with
|
|
|
153
196
|
embedding vector length. A model that returns 768-dimensional vectors needs a 768-dimensional
|
|
154
197
|
`vector` column; a 1536-dimensional migration will not be compatible with it. The install
|
|
155
198
|
generator rejects non-positive or non-integer `--dimensions` values so an invalid vector
|
|
156
|
-
size does not land in a migration.
|
|
199
|
+
size does not land in a migration. `RubyLLMEmbedder` requests explicitly configured
|
|
200
|
+
`dimensions:` from the provider (models like `text-embedding-3-*` support shortening) and
|
|
201
|
+
raises a clear error when the model's output does not match the configured dimensions.
|
|
157
202
|
|
|
158
203
|
For production recall performance, add one approximate vector index after the table has
|
|
159
204
|
representative data. HNSW is the recommended default for read-heavy applications because it
|
|
@@ -317,9 +362,28 @@ with the old `semantic` kind value.
|
|
|
317
362
|
|
|
318
363
|
## Tuning and maintenance
|
|
319
364
|
|
|
320
|
-
Observation
|
|
321
|
-
|
|
322
|
-
|
|
365
|
+
Observation uses a scope-and-turn claim before extraction. While a claim lease is live and
|
|
366
|
+
the turn has not completed, calls for the same scope and turn raise
|
|
367
|
+
`Engram::ObservationInProgressError` instead of reporting success without doing the work; a
|
|
368
|
+
completed marker suppresses later calls (returning no decisions) until its configured `ttl`
|
|
369
|
+
expires. `ObserveJob` retries `ObservationInProgressError` with polynomial backoff that
|
|
370
|
+
outlasts the default lease. Direct `observe` callers should also treat the error as retryable.
|
|
371
|
+
The in-memory adapter releases failures immediately. Generic Rails cache release
|
|
372
|
+
is deliberately a no-op until lease expiry because ActiveSupport cache has no atomic
|
|
373
|
+
compare-and-delete. In Rails, use a shared cache with atomic `unless_exist` writes for
|
|
374
|
+
cross-process coordination. Set `lease_ttl` longer than the longest expected observation but
|
|
375
|
+
much shorter than the completed-marker `ttl`; after a worker crash (or an overlong
|
|
376
|
+
observation), the lease expires so a retry can proceed.
|
|
377
|
+
|
|
378
|
+
Lease expiry can permit old and new workers to overlap; claims are not fencing tokens or an
|
|
379
|
+
ownership guarantee. Successful work records completion but cannot safely delete a possibly
|
|
380
|
+
newer cache claim. This is idempotency coordination, not crash-proof exactly-once persistence. An observation
|
|
381
|
+
can apply multiple decisions, and the memory writes plus completed marker are not one
|
|
382
|
+
transaction. A crash, cache outage, or lease expiry between those operations can permit a
|
|
383
|
+
retry after some decisions were already written. Applications needing stronger guarantees
|
|
384
|
+
must supply transactional persistence/outbox coordination appropriate to their store.
|
|
385
|
+
If writing the completed marker fails after memory writes succeed, the current lease still
|
|
386
|
+
suppresses retries until it expires; after expiry the turn may replay and repeat those writes.
|
|
323
387
|
|
|
324
388
|
Recall is plain similarity search by default. You can blend in importance and recency:
|
|
325
389
|
|
|
@@ -338,6 +402,27 @@ Prune memories you no longer need:
|
|
|
338
402
|
current_user.memory.forget_stale(older_than: 90 * 24 * 60 * 60, min_importance: 0.7)
|
|
339
403
|
```
|
|
340
404
|
|
|
405
|
+
If you change embedder configuration (model/provider/dimensions), or if legacy memories were
|
|
406
|
+
written without embedding provenance, run a scoped rebuild to refresh vectors and metadata:
|
|
407
|
+
|
|
408
|
+
```bash
|
|
409
|
+
bundle exec rake "engram:rebuild_embeddings[user:42]"
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
The task ships with the gem and is loaded by the Railtie, so it is available inside Rails
|
|
413
|
+
applications and boots the app environment (initializers included) before running. Outside
|
|
414
|
+
Rails, call `memory.rebuild_embeddings` directly.
|
|
415
|
+
|
|
416
|
+
By default, only stale rows are rewritten. Set `STALE_ONLY=false` to rebuild all rows in
|
|
417
|
+
the scope, and `BATCH_SIZE=<n>` to tune write size.
|
|
418
|
+
|
|
419
|
+
The rebuild has no dry-run mode and updates each row as it goes. It assumes the store exposes
|
|
420
|
+
a stable, finite, scope-isolated traversal; malformed adapters that repeat forever cannot be
|
|
421
|
+
made deterministic by the rebuild. Rows without IDs are counted and skipped. Choose a batch
|
|
422
|
+
size that fits provider and database limits, take a backup first, and monitor provider usage,
|
|
423
|
+
errors, and database load. The final summary reports processed, updated, skipped, and failed
|
|
424
|
+
counts; failures also list record IDs and cause the rake task to exit unsuccessfully.
|
|
425
|
+
|
|
341
426
|
## Observability
|
|
342
427
|
|
|
343
428
|
When ActiveSupport is loaded, Engram emits `ActiveSupport::Notifications` events for the
|
|
@@ -478,8 +563,9 @@ gem unpack engram-*.gem --target /tmp/engram-package-check
|
|
|
478
563
|
- v0.1 (done): recall + inject foundation, adapters, Rails + RubyLLM integration.
|
|
479
564
|
- v0.2 (done): extract and consolidate (ADD / UPDATE / FORGET), background jobs.
|
|
480
565
|
- v0.3 (done): idempotent observation, importance/recency recall, forgetting and decay.
|
|
481
|
-
- v0.4 (
|
|
482
|
-
-
|
|
566
|
+
- v0.4 (done): memory kinds, persistence policy, typed recall filters, safer injection, and observability hooks.
|
|
567
|
+
- v0.5 (in progress): embedding provenance and scoped embedding rebuild operations.
|
|
568
|
+
- later: additional storage backends and larger real-provider eval benchmarks.
|
|
483
569
|
|
|
484
570
|
## License
|
|
485
571
|
|
|
@@ -8,21 +8,53 @@ module Engram
|
|
|
8
8
|
class InMemoryProcessedTurns
|
|
9
9
|
include Ports::ProcessedTurns
|
|
10
10
|
|
|
11
|
-
def initialize
|
|
12
|
-
@
|
|
11
|
+
def initialize(lease_ttl: 300, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
12
|
+
@lease_ttl = lease_ttl
|
|
13
|
+
@clock = clock
|
|
14
|
+
@claims = {}
|
|
15
|
+
@completed = Set.new
|
|
16
|
+
@mutex = Mutex.new
|
|
13
17
|
end
|
|
14
18
|
|
|
15
|
-
def
|
|
16
|
-
|
|
19
|
+
def claim(scope:, key:)
|
|
20
|
+
scoped_key = [scope, key]
|
|
21
|
+
@mutex.synchronize do
|
|
22
|
+
return if @completed.include?(scoped_key)
|
|
23
|
+
live = @claims[scoped_key]
|
|
24
|
+
return if live && live.last > @clock.call
|
|
25
|
+
|
|
26
|
+
token = Object.new
|
|
27
|
+
@claims[scoped_key] = [token, @clock.call + @lease_ttl]
|
|
28
|
+
token
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def complete(scope:, key:, claim:)
|
|
33
|
+
@mutex.synchronize do
|
|
34
|
+
scoped_key = [scope, key]
|
|
35
|
+
return false unless @claims.dig(scoped_key, 0).equal?(claim)
|
|
36
|
+
@completed << scoped_key
|
|
37
|
+
@claims.delete(scoped_key)
|
|
38
|
+
true
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def release(scope:, key:, claim:)
|
|
43
|
+
@mutex.synchronize do
|
|
44
|
+
scoped_key = [scope, key]
|
|
45
|
+
@claims.delete(scoped_key) if @claims.dig(scoped_key, 0).equal?(claim)
|
|
46
|
+
end
|
|
17
47
|
end
|
|
18
48
|
|
|
19
|
-
def
|
|
20
|
-
@
|
|
21
|
-
key
|
|
49
|
+
def completed?(scope:, key:)
|
|
50
|
+
@mutex.synchronize { @completed.include?([scope, key]) }
|
|
22
51
|
end
|
|
23
52
|
|
|
24
53
|
def clear
|
|
25
|
-
@
|
|
54
|
+
@mutex.synchronize do
|
|
55
|
+
@claims.clear
|
|
56
|
+
@completed.clear
|
|
57
|
+
end
|
|
26
58
|
end
|
|
27
59
|
end
|
|
28
60
|
end
|
|
@@ -20,37 +20,52 @@ module Engram
|
|
|
20
20
|
record
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
def search(embedding:, scope:, limit:, kinds: nil)
|
|
23
|
+
def search(embedding:, scope:, limit:, kinds: nil, embedding_metadata: nil)
|
|
24
|
+
Engram::EmbeddingMetadata.validate_query!(embedding, embedding_metadata)
|
|
24
25
|
allowed_kinds = normalize_kinds(kinds)
|
|
25
26
|
|
|
26
|
-
@records
|
|
27
|
+
results = @records
|
|
27
28
|
.values
|
|
28
29
|
.select { |r| searchable?(r, scope, allowed_kinds) }
|
|
29
30
|
.map { |r| [r, Engram::Math.cosine_similarity(embedding, r.embedding)] }
|
|
30
31
|
.sort_by { |(_, score)| -score }
|
|
31
32
|
.first(limit)
|
|
32
33
|
.map { |(record, _)| record }
|
|
34
|
+
|
|
35
|
+
results.each { |record| Engram::EmbeddingMetadata.validate_record!(record, embedding, embedding_metadata) }
|
|
36
|
+
results
|
|
33
37
|
end
|
|
34
38
|
|
|
35
|
-
def all(scope:)
|
|
36
|
-
@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
|
|
37
45
|
end
|
|
38
46
|
|
|
39
|
-
def update(id:, record:)
|
|
40
|
-
|
|
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
|
|
41
51
|
|
|
42
52
|
record.id = id
|
|
43
53
|
@records[id] = record
|
|
44
54
|
end
|
|
45
55
|
|
|
46
|
-
def delete(id:)
|
|
56
|
+
def delete(scope:, id:)
|
|
57
|
+
return 0 unless @records[id]&.scope == scope
|
|
58
|
+
|
|
47
59
|
@records.delete(id)
|
|
60
|
+
1
|
|
48
61
|
end
|
|
49
62
|
|
|
50
|
-
def touch(id:, at: Time.now)
|
|
63
|
+
def touch(scope:, id:, at: Time.now)
|
|
51
64
|
record = @records[id]
|
|
52
|
-
|
|
53
|
-
|
|
65
|
+
return 0 unless record&.scope == scope
|
|
66
|
+
|
|
67
|
+
record.last_accessed_at = at
|
|
68
|
+
1
|
|
54
69
|
end
|
|
55
70
|
|
|
56
71
|
def clear
|
|
@@ -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|
|
|
@@ -28,7 +28,8 @@ module Engram
|
|
|
28
28
|
to_record(row)
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
def search(embedding:, scope:, limit:, kinds: nil)
|
|
31
|
+
def search(embedding:, scope:, limit:, kinds: nil, embedding_metadata: nil)
|
|
32
|
+
Engram::EmbeddingMetadata.validate_query!(embedding, embedding_metadata)
|
|
32
33
|
query = model.where(scope: scope)
|
|
33
34
|
normalized_kinds = normalize_kinds(kinds)
|
|
34
35
|
query = query.where(kind: normalized_kinds) if normalized_kinds
|
|
@@ -37,30 +38,41 @@ module Engram
|
|
|
37
38
|
.nearest_neighbors(:embedding, embedding, distance: "cosine")
|
|
38
39
|
.limit(limit)
|
|
39
40
|
.map { |row| to_record(row) }
|
|
41
|
+
.tap { |records| validate_query_records!(records, embedding, embedding_metadata) }
|
|
40
42
|
end
|
|
41
43
|
|
|
42
|
-
def all(scope:)
|
|
43
|
-
model.where(scope: scope).
|
|
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) }
|
|
44
50
|
end
|
|
45
51
|
|
|
46
|
-
def update(id:, record:)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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}"
|
|
56
68
|
end
|
|
57
69
|
|
|
58
|
-
def delete(id:)
|
|
59
|
-
model.where(id: id).delete_all
|
|
70
|
+
def delete(scope:, id:)
|
|
71
|
+
model.where(id: id, scope: scope).delete_all
|
|
60
72
|
end
|
|
61
73
|
|
|
62
|
-
def touch(id:, at: Time.now)
|
|
63
|
-
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)
|
|
64
76
|
end
|
|
65
77
|
|
|
66
78
|
private
|
|
@@ -69,6 +81,12 @@ module Engram
|
|
|
69
81
|
raise Engram::Error, "memory scope cannot be nil" if scope.nil?
|
|
70
82
|
end
|
|
71
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
|
+
|
|
72
90
|
def model
|
|
73
91
|
@model ||= resolve_default_model
|
|
74
92
|
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:
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
|
@@ -15,7 +15,13 @@ module Engram
|
|
|
15
15
|
|
|
16
16
|
def reconcile_all(candidates:, scope:)
|
|
17
17
|
Array(candidates).map do |candidate|
|
|
18
|
-
nearest =
|
|
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
|
|
@@ -56,23 +56,35 @@ module Engram
|
|
|
56
56
|
candidates = Array(candidates)
|
|
57
57
|
return [] if candidates.empty?
|
|
58
58
|
|
|
59
|
+
neighbors = neighbor_map(candidates, scope)
|
|
59
60
|
result = @completion.complete(
|
|
60
61
|
system: SYSTEM,
|
|
61
|
-
user: JSON.generate(payload(candidates,
|
|
62
|
+
user: JSON.generate(payload(candidates, neighbors)),
|
|
62
63
|
schema: SCHEMA
|
|
63
64
|
)
|
|
64
|
-
map_decisions(decisions(result), candidates)
|
|
65
|
+
map_decisions(decisions(result), candidates, neighbors)
|
|
65
66
|
end
|
|
66
67
|
|
|
67
68
|
private
|
|
68
69
|
|
|
69
|
-
def
|
|
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)
|
|
70
83
|
items = candidates.each_with_index.map do |candidate, index|
|
|
71
|
-
existing = @store.search(embedding: candidate.embedding, scope: scope, limit: @neighbors)
|
|
72
84
|
{
|
|
73
85
|
index: index,
|
|
74
86
|
candidate: candidate.content,
|
|
75
|
-
existing:
|
|
87
|
+
existing: neighbors[index].map { |r| {id: r.id, content: r.content} }
|
|
76
88
|
}
|
|
77
89
|
end
|
|
78
90
|
{candidates: items}
|
|
@@ -81,19 +93,34 @@ module Engram
|
|
|
81
93
|
def decisions(result)
|
|
82
94
|
return [] unless result.is_a?(Hash)
|
|
83
95
|
|
|
84
|
-
result["decisions"] || result[:decisions] || []
|
|
96
|
+
decisions = result["decisions"] || result[:decisions] || []
|
|
97
|
+
decisions.is_a?(Array) ? decisions : []
|
|
85
98
|
end
|
|
86
99
|
|
|
87
|
-
def map_decisions(raw, candidates)
|
|
100
|
+
def map_decisions(raw, candidates, neighbors)
|
|
101
|
+
seen = Set.new
|
|
88
102
|
raw.filter_map do |decision|
|
|
103
|
+
next unless decision.is_a?(Hash)
|
|
104
|
+
|
|
89
105
|
decision = decision.transform_keys(&:to_s)
|
|
90
106
|
index = decision["index"]
|
|
91
|
-
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
|
|
92
118
|
|
|
119
|
+
seen.add(index)
|
|
93
120
|
Engram::Decision.new(
|
|
94
|
-
action:
|
|
121
|
+
action: action.to_sym,
|
|
95
122
|
candidate: candidates[index],
|
|
96
|
-
target_id:
|
|
123
|
+
target_id: target_id,
|
|
97
124
|
reason: decision["reason"]
|
|
98
125
|
)
|
|
99
126
|
end
|