engram 0.6.0 → 0.8.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 +33 -0
- data/README.md +172 -9
- data/lib/engram/adapters/in_memory_store.rb +55 -23
- data/lib/engram/adapters/pgvector_store.rb +37 -1
- data/lib/engram/configuration.rb +4 -1
- data/lib/engram/integrations/ruby_llm.rb +15 -7
- data/lib/engram/internal/candidate_integrity.rb +2 -1
- data/lib/engram/memory.rb +31 -10
- data/lib/engram/ports/memory_store.rb +16 -2
- data/lib/engram/rails/tasks.rake +15 -0
- data/lib/engram/railtie.rb +1 -3
- data/lib/engram/record.rb +11 -3
- data/lib/engram/use_cases/forget_expired.rb +43 -0
- data/lib/engram/use_cases/inject.rb +34 -6
- data/lib/engram/use_cases/recall.rb +40 -3
- data/lib/engram/version.rb +1 -1
- data/lib/engram.rb +1 -0
- data/lib/generators/engram/expiry_generator.rb +30 -0
- data/lib/generators/engram/templates/add_expiry_to_engram_memories.rb.tt +12 -0
- data/lib/generators/engram/templates/create_engram_memories.rb.tt +4 -0
- data/lib/generators/engram/templates/initializer.rb.tt +5 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b3d370df000e9c12a40df932919f0859d9eb7623270ed5aeaa781747f239d865
|
|
4
|
+
data.tar.gz: 2c49fd3e8126a510f49b711a9ccf9d159ac01b6e540ef2ab168e7e51fa6509f3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 161c8e9c199352d663e0ce670f3a6dc76ac98aba0b429566a1c3c96fa8ee5c7db260a449355f449b11dfdb25de4b85f207ca263fcffa6a76248d1269632ecddc
|
|
7
|
+
data.tar.gz: 1c3867577e6a86128fa4a4036b64cc2790754336c4cc462495284f25f5a7ac63ac56be24694ffb9578d2b50999bde325ca4ced68b6d8e0aed9143234108b0888
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,39 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.8.0] - 2026-09-15
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Optional `expires_at` on memories. Expired records are excluded from search and injection while remaining available for inspection and deletion.
|
|
13
|
+
- `engram:expiry` generator for existing Rails apps, with a concurrent index for scoped expiry queries.
|
|
14
|
+
- Scoped `forget_expired` cleanup with batching, dry runs, and a Rails task. Deletion rechecks the deadline so concurrent expiry extensions are preserved.
|
|
15
|
+
|
|
16
|
+
## [0.7.0] - 2026-09-13
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- `Memory#forget(id:)` deletes one memory in the current scope, returning `1` when deleted
|
|
20
|
+
or `0` when missing. Explicit deletion bypasses persistence hooks and emits `forget.engram`
|
|
21
|
+
with the deleted count. Arrays, ranges, nil, and blank IDs are rejected.
|
|
22
|
+
- Optional `min_similarity:` on recall, injection, and `Engram.with_memory`, with
|
|
23
|
+
`config.recall_min_similarity` as the facade default. Filters cosine similarity before
|
|
24
|
+
importance/recency ranking and touching; nil preserves existing top-k behavior.
|
|
25
|
+
- Optional `max_bytes:` on injection and `Engram.with_memory`, with
|
|
26
|
+
`config.injection_max_bytes` as the default. Includes the complete appended memory block,
|
|
27
|
+
skips whole memories that do not fit, and preserves the original prompt.
|
|
28
|
+
- Metrics for filtered candidates, injected/skipped memories, and appended bytes.
|
|
29
|
+
- `MIN_SIMILARITY` eval control and negative-query false-positive/abstention measurements.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
- Load `ObserveJob` on first use so `observe_later` works before another job has loaded
|
|
33
|
+
`ActiveJob::Base` in a Rails application.
|
|
34
|
+
- Forward RubyLLM streaming blocks and retain the memory wrapper when fluent chat methods
|
|
35
|
+
return the underlying chat, so chained configuration does not bypass memory injection.
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
- Recall rejects negative, fractional, nil, and string limits with `ArgumentError`; use an
|
|
39
|
+
integer. A zero limit skips embedding and search. A zero injection byte budget skips recall.
|
|
40
|
+
|
|
8
41
|
## [0.6.0] - 2026-07-24
|
|
9
42
|
|
|
10
43
|
### Added
|
data/README.md
CHANGED
|
@@ -58,6 +58,8 @@ chat.ask("Why am I being rate limited?")
|
|
|
58
58
|
- RubyLLM embedder and completion adapters for provider-backed embeddings and extraction.
|
|
59
59
|
- Canonical memory kinds: `fact`, `preference`, `instruction`, and `episodic`.
|
|
60
60
|
- Typed recall filters and typed, escaped memory injection.
|
|
61
|
+
- Optional cosine relevance thresholds and byte budgets for injected context.
|
|
62
|
+
- RubyLLM streaming and fluent chat configuration through the memory wrapper.
|
|
61
63
|
- Persistence policy that rejects obvious secrets and transient task-progress updates before storage.
|
|
62
64
|
- Idempotent observation, recency/importance-aware ranking, recall touching, and stale-memory pruning.
|
|
63
65
|
|
|
@@ -74,7 +76,7 @@ explicitly version-gated:
|
|
|
74
76
|
- Core types and facade: `Engram::Memory`, `Engram::Record`, `Engram::Decision`, `Engram::PersistencePolicy`, and `Engram.with_memory`.
|
|
75
77
|
- Store and adapter ports: `Engram::Ports::MemoryStore`, `Engram::Ports::Embedder`, `Engram::Ports::Completion`.
|
|
76
78
|
- 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`, `rebuild_embeddings`, and `memories_from_source`.
|
|
79
|
+
- Lifecycle methods in `Engram::Memory`: `add`, `recall`, `inject_into`, `observe`, `observe_later`, `forget`, `forget_stale`, `forget_expired`, `rebuild_embeddings`, and `memories_from_source`.
|
|
78
80
|
- RubyLLM adapter contract points and evaluator entrypoints (`rake eval`, `rake eval:real`).
|
|
79
81
|
|
|
80
82
|
### Backward-compatibility commitments (pre-1.0)
|
|
@@ -223,6 +225,25 @@ class AddEngramMemoryEmbeddingIndex < ActiveRecord::Migration[8.0]
|
|
|
223
225
|
end
|
|
224
226
|
```
|
|
225
227
|
|
|
228
|
+
### Filtered vector search
|
|
229
|
+
|
|
230
|
+
Keep the generated B-tree index on `scope`. With an approximate vector index, pgvector
|
|
231
|
+
applies scope, kind, and expiry filters after the index scan. This can return fewer than `limit`
|
|
232
|
+
memories even when enough matching rows exist.
|
|
233
|
+
|
|
234
|
+
On pgvector 0.8.0+, HNSW iterative scans can search further:
|
|
235
|
+
|
|
236
|
+
```ruby
|
|
237
|
+
Engram::MemoryRecord.transaction do
|
|
238
|
+
Engram::MemoryRecord.connection.execute("SET LOCAL hnsw.iterative_scan = strict_order")
|
|
239
|
+
current_user.memory.recall("billing preferences", limit: 5)
|
|
240
|
+
end
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
The setting lasts for the transaction. Iterative scans add latency and still have scan
|
|
244
|
+
limits. Compare them with exact search on your scoped data before enabling them.
|
|
245
|
+
See [pgvector's filtering docs](https://github.com/pgvector/pgvector#filtering).
|
|
246
|
+
|
|
226
247
|
## Model/provider configuration
|
|
227
248
|
|
|
228
249
|
Engram is model-provider agnostic. The core only depends on two ports:
|
|
@@ -272,6 +293,25 @@ chat.ask("why am I being rate limited?")
|
|
|
272
293
|
# recall + inject happen automatically before the model sees the message
|
|
273
294
|
```
|
|
274
295
|
|
|
296
|
+
Streaming and chained chat configuration work through the wrapper:
|
|
297
|
+
|
|
298
|
+
```ruby
|
|
299
|
+
chat = Engram.with_memory(
|
|
300
|
+
RubyLLM.chat,
|
|
301
|
+
memory: current_user.memory,
|
|
302
|
+
kinds: [:fact, :preference],
|
|
303
|
+
min_similarity: 0.5, # tune for your embedding model
|
|
304
|
+
max_bytes: 8_000
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
response = chat.with_instructions("Keep answers concise.").ask("Which plan am I on?") do |chunk|
|
|
308
|
+
print chunk.content
|
|
309
|
+
end
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
`ask` forwards request options and the streaming block to RubyLLM and returns its final
|
|
313
|
+
response. `with_memory` captures memory defaults when the wrapper is created.
|
|
314
|
+
|
|
275
315
|
## Automatic memory
|
|
276
316
|
|
|
277
317
|
Instead of adding facts by hand, let engram derive them from a conversation turn. It
|
|
@@ -348,8 +388,8 @@ Engram.configure do |config|
|
|
|
348
388
|
end
|
|
349
389
|
```
|
|
350
390
|
|
|
351
|
-
Write-content filtering and transformation are not deletion authorization.
|
|
352
|
-
validates scope, target existence, candidate integrity, and provenance, but it does not run
|
|
391
|
+
Write-content filtering and transformation are not deletion authorization. A consolidation
|
|
392
|
+
`forget` decision still validates scope, target existence, candidate integrity, and provenance, but it does not run
|
|
353
393
|
`before_persist` or the policy's `call` method; this allows secret, transient, and redacted
|
|
354
394
|
memories to be removed. A custom policy can additionally control destructive decisions by
|
|
355
395
|
implementing `allow_destructive?(record)` and returning exactly `true` or `false`. Policies
|
|
@@ -482,6 +522,100 @@ with the old `semantic` kind value.
|
|
|
482
522
|
|
|
483
523
|
## Tuning and maintenance
|
|
484
524
|
|
|
525
|
+
### Recall thresholds and injection limits
|
|
526
|
+
|
|
527
|
+
Recall returns the nearest matches even for unrelated queries. Set a minimum cosine
|
|
528
|
+
similarity to exclude weak matches and a byte limit to keep injected context small:
|
|
529
|
+
|
|
530
|
+
```ruby
|
|
531
|
+
Engram.configure do |config|
|
|
532
|
+
config.recall_min_similarity = 0.5 # tune for your embedding model
|
|
533
|
+
config.injection_max_bytes = 8_000
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
memory.recall("billing preferences", min_similarity: 0.6)
|
|
537
|
+
memory.inject_into(prompt, query: "billing preferences", max_bytes: 4_000)
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
`min_similarity` accepts a finite number in `[-1, 1]`. It filters candidates before
|
|
541
|
+
importance/recency ranking and touching, and can return no matches. Filtering happens in
|
|
542
|
+
Ruby; custom stores keep their existing search signature. It does not refill the candidate
|
|
543
|
+
pool or suppress embedding compatibility errors.
|
|
544
|
+
|
|
545
|
+
`max_bytes` limits the appended memory section, including escaped text, headers, and tags.
|
|
546
|
+
It excludes the original prompt and chat history and counts bytes, not tokens. Memories
|
|
547
|
+
are considered in recall order; those that do not fit are skipped without truncation.
|
|
548
|
+
If none fit, the prompt is unchanged. With `touch_on_recall`, retrieved memories are touched
|
|
549
|
+
even if the byte limit later excludes them.
|
|
550
|
+
|
|
551
|
+
Both settings default to nil. Pass nil explicitly to override a configured value.
|
|
552
|
+
`limit` and `max_bytes` require non-negative integers, except that `max_bytes: nil` removes
|
|
553
|
+
the cap. `limit: 0` skips embedding and search; `max_bytes: 0` skips recall during injection.
|
|
554
|
+
Invalid values raise `ArgumentError`.
|
|
555
|
+
|
|
556
|
+
### Memory expiry
|
|
557
|
+
|
|
558
|
+
Set a deadline for temporary memories:
|
|
559
|
+
|
|
560
|
+
```ruby
|
|
561
|
+
user.memory.add("Trial access is active", expires_at: 7.days.from_now)
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
`expires_at` accepts a Time (including Rails time-zone values) or nil. Nil is the default
|
|
565
|
+
and never expires. A memory is expired at `expires_at <= Time.now`. Both built-in stores
|
|
566
|
+
exclude expired records before the search limit, so they no longer participate in recall
|
|
567
|
+
or consolidation. Injection checks expiry again when rendering.
|
|
568
|
+
|
|
569
|
+
Expiry does not delete rows. `memory.all` still returns them, and `memory.forget(id:)` can
|
|
570
|
+
delete them. A new observation may recreate the same fact from retained source data.
|
|
571
|
+
Custom extractors or `before_persist` can set expiry with `record.with(expires_at: deadline)`;
|
|
572
|
+
Engram does not infer deadlines from conversation text. Store updates replace expiry with
|
|
573
|
+
the supplied record's value, including nil.
|
|
574
|
+
|
|
575
|
+
New Rails installs include the expiry column and index. For a table created by 0.7.0 or
|
|
576
|
+
earlier, generate and run the upgrade migration:
|
|
577
|
+
|
|
578
|
+
```sh
|
|
579
|
+
bin/rails generate engram:expiry
|
|
580
|
+
bin/rails db:migrate
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
The migration adds a nullable datetime column and builds a partial index on scope and
|
|
584
|
+
expiry concurrently. Existing rows keep their data and do not expire. Reads and
|
|
585
|
+
non-expiring writes continue to work before migration; expiring writes raise an error
|
|
586
|
+
instead of losing the deadline. Restart app processes after migrating so ActiveRecord
|
|
587
|
+
refreshes its cached schema. If you use a custom table, adjust the generated migration.
|
|
588
|
+
|
|
589
|
+
Custom stores must persist `expires_at` and exclude expired records before limiting search
|
|
590
|
+
results. Recall also discards any expired records a custom store returns, but cannot fill
|
|
591
|
+
the gaps left by a store that filters after its limit.
|
|
592
|
+
|
|
593
|
+
To remove expired rows, preview the count, then run cleanup:
|
|
594
|
+
|
|
595
|
+
```ruby
|
|
596
|
+
user.memory.forget_expired(dry_run: true)
|
|
597
|
+
# => {matched: 12, deleted: 0, dry_run: true}
|
|
598
|
+
user.memory.forget_expired(batch_size: 500)
|
|
599
|
+
# => {matched: 12, deleted: 12, dry_run: false}
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
The Rails task requires a scope:
|
|
603
|
+
|
|
604
|
+
```sh
|
|
605
|
+
DRY_RUN=true BATCH_SIZE=500 bin/rails 'engram:forget_expired[user:42]'
|
|
606
|
+
BATCH_SIZE=500 bin/rails 'engram:forget_expired[user:42]'
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
Cleanup uses one cutoff for the run and reads only IDs in batches. It rechecks scope and
|
|
610
|
+
expiry when deleting, so a concurrent extension or removal of the deadline is preserved.
|
|
611
|
+
`matched` can exceed `deleted` if records change during the run; dry-run counts are advisory.
|
|
612
|
+
Like `forget(id:)`, this is explicit deletion and bypasses persistence hooks and policies.
|
|
613
|
+
Errors stop the run; completed batches stay deleted and a retry can process the remainder.
|
|
614
|
+
Custom stores need the optional `expired_ids` and `delete_expired` methods for cleanup.
|
|
615
|
+
The `forget_expired.engram` event reports counts without memory content or IDs.
|
|
616
|
+
|
|
617
|
+
### Observation and maintenance
|
|
618
|
+
|
|
485
619
|
Observation uses a scope-and-turn claim before extraction. While a claim lease is live and
|
|
486
620
|
the turn has not completed, calls for the same scope and turn raise
|
|
487
621
|
`Engram::ObservationInProgressError` instead of reporting success without doing the work; a
|
|
@@ -515,7 +649,24 @@ Engram.configure do |config|
|
|
|
515
649
|
end
|
|
516
650
|
```
|
|
517
651
|
|
|
518
|
-
|
|
652
|
+
Delete a specific memory:
|
|
653
|
+
|
|
654
|
+
```ruby
|
|
655
|
+
record = current_user.memory.add("Prefers tea")
|
|
656
|
+
current_user.memory.forget(id: record.id) # => 1
|
|
657
|
+
current_user.memory.forget(id: record.id) # => 0 (already deleted)
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
`forget` deletes one record within the memory's scope and returns the affected row count.
|
|
661
|
+
Missing records and IDs belonging to another scope return `0`. Pass the ID returned by
|
|
662
|
+
the store; integers and non-blank strings are accepted. Arrays, ranges, and nil raise
|
|
663
|
+
`ArgumentError`.
|
|
664
|
+
|
|
665
|
+
This is an explicit application operation: authorize the request before calling it.
|
|
666
|
+
It does not run extraction, consolidation, or persistence hooks, including `allow_destructive?`.
|
|
667
|
+
Source messages and processed-turn markers are kept; a later observation can recreate the memory.
|
|
668
|
+
|
|
669
|
+
Prune stale memories:
|
|
519
670
|
|
|
520
671
|
```ruby
|
|
521
672
|
# Forget memories untouched for 90 days, but keep anything important
|
|
@@ -549,6 +700,8 @@ When ActiveSupport is loaded, Engram emits `ActiveSupport::Notifications` events
|
|
|
549
700
|
main memory pipeline:
|
|
550
701
|
|
|
551
702
|
- `add.engram`
|
|
703
|
+
- `forget.engram`
|
|
704
|
+
- `forget_expired.engram`
|
|
552
705
|
- `recall.engram`
|
|
553
706
|
- `inject.engram`
|
|
554
707
|
- `observe.engram`
|
|
@@ -580,6 +733,11 @@ ActiveSupport::Notifications.subscribe(/\.engram\z/) do |name, _started, _finish
|
|
|
580
733
|
end
|
|
581
734
|
```
|
|
582
735
|
|
|
736
|
+
Recall events include `candidate_count`, `filtered_count`, `result_count`, and
|
|
737
|
+
`min_similarity` when set. Injection events include the input `memory_count`,
|
|
738
|
+
`injected_count`, `skipped_count`, `injected_bytes`, and `max_bytes` when set.
|
|
739
|
+
`forget.engram` reports `deleted_count` without the record ID or content.
|
|
740
|
+
|
|
583
741
|
Avoid adding memory content or raw prompts to subscriber logs; recalled content is
|
|
584
742
|
user-derived and should be treated as sensitive application data.
|
|
585
743
|
|
|
@@ -592,7 +750,8 @@ user-derived and should be treated as sensitive application data.
|
|
|
592
750
|
- Configure ActiveJob for `observe_later`; keep automatic observation off the request path.
|
|
593
751
|
- Configure `Engram::Rails::CacheProcessedTurns` or another persistent processed-turns adapter for retries.
|
|
594
752
|
- Review persistence policy settings and add app-specific redaction/denylist patterns.
|
|
595
|
-
-
|
|
753
|
+
- Calibrate `recall_min_similarity` and set `injection_max_bytes`, recall limits, and `kinds:` filters.
|
|
754
|
+
- Check filtered pgvector recall coverage and latency on representative tenant sizes.
|
|
596
755
|
- Run the deterministic test/eval suite plus pgvector integration tests before release.
|
|
597
756
|
|
|
598
757
|
## How it works
|
|
@@ -667,9 +826,10 @@ safe to run in CI as a smoke test.
|
|
|
667
826
|
The harness reports recall@k over labelled relevant memories, a labelled precision
|
|
668
827
|
proxy@k, near-distractor retrieval rate, contradiction-pair full recall, extraction
|
|
669
828
|
structured-output parsing cases, consolidation decision cases, and a heuristic duplicate-add
|
|
670
|
-
baseline.
|
|
671
|
-
|
|
672
|
-
|
|
829
|
+
baseline. Set `MIN_SIMILARITY=0.5` on either eval task to test a threshold. With a semantic
|
|
830
|
+
embedder, it also reports how often unrelated queries retrieve memories and how often
|
|
831
|
+
recall returns nothing. Compare these against positive-query recall when tuning the
|
|
832
|
+
threshold. NullEmbedder results only check mechanics; they do not measure semantic quality.
|
|
673
833
|
|
|
674
834
|
Before opening a release PR, also verify the gem package:
|
|
675
835
|
|
|
@@ -684,7 +844,10 @@ gem unpack engram-*.gem --target /tmp/engram-package-check
|
|
|
684
844
|
- v0.2 (done): extract and consolidate (ADD / UPDATE / FORGET), background jobs.
|
|
685
845
|
- v0.3 (done): idempotent observation, importance/recency recall, forgetting and decay.
|
|
686
846
|
- v0.4 (done): memory kinds, persistence policy, typed recall filters, safer injection, and observability hooks.
|
|
687
|
-
- v0.5 (
|
|
847
|
+
- v0.5 (done): embedding provenance and scoped embedding rebuild operations.
|
|
848
|
+
- v0.6 (done): structured provenance, source impact lookup, and grounding reports.
|
|
849
|
+
- v0.7 (done): recall thresholds, injection limits, scoped deletion, and RubyLLM streaming fixes.
|
|
850
|
+
- v0.8 (done): explicit memory expiry, Rails upgrade migration, and scoped cleanup with dry runs.
|
|
688
851
|
- later: additional storage backends and larger real-provider eval benchmarks.
|
|
689
852
|
|
|
690
853
|
## License
|
|
@@ -10,23 +10,26 @@ module Engram
|
|
|
10
10
|
def initialize
|
|
11
11
|
@records = {}
|
|
12
12
|
@sequence = 0
|
|
13
|
+
@mutex = Mutex.new
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def add(record)
|
|
16
17
|
validate_scope!(record.scope)
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
@mutex.synchronize do
|
|
20
|
+
record.id = (@sequence += 1)
|
|
21
|
+
@records[record.id] = record
|
|
22
|
+
record
|
|
23
|
+
end
|
|
21
24
|
end
|
|
22
25
|
|
|
23
26
|
def search(embedding:, scope:, limit:, kinds: nil, embedding_metadata: nil)
|
|
24
27
|
Engram::EmbeddingMetadata.validate_query!(embedding, embedding_metadata)
|
|
25
28
|
allowed_kinds = normalize_kinds(kinds)
|
|
29
|
+
now = Time.now
|
|
26
30
|
|
|
27
|
-
results = @records
|
|
28
|
-
.
|
|
29
|
-
.select { |r| searchable?(r, scope, allowed_kinds) }
|
|
31
|
+
results = @mutex.synchronize { @records.values }
|
|
32
|
+
.select { |r| searchable?(r, scope, allowed_kinds) && !r.expired?(at: now) }
|
|
30
33
|
.map { |r| [r, Engram::Math.cosine_similarity(embedding, r.embedding)] }
|
|
31
34
|
.sort_by { |(_, score)| -score }
|
|
32
35
|
.first(limit)
|
|
@@ -37,7 +40,7 @@ module Engram
|
|
|
37
40
|
end
|
|
38
41
|
|
|
39
42
|
def all(scope:, limit: nil, offset: 0, after_id: nil)
|
|
40
|
-
records = @records.values.select { |r| r.scope == scope }.sort_by { |record| record.id }
|
|
43
|
+
records = @mutex.synchronize { @records.values }.select { |r| r.scope == scope }.sort_by { |record| record.id }
|
|
41
44
|
records = records.drop_while { |record| !after_id.nil? && record.id && record.id <= after_id }
|
|
42
45
|
records = records.drop(offset) if offset > 0
|
|
43
46
|
records = records.take(limit) if limit
|
|
@@ -45,36 +48,65 @@ module Engram
|
|
|
45
48
|
end
|
|
46
49
|
|
|
47
50
|
def existing_ids(scope:, ids:)
|
|
48
|
-
ids.uniq.select { |id| @records[id]&.scope == scope }
|
|
51
|
+
@mutex.synchronize { ids.uniq.select { |id| @records[id]&.scope == scope } }
|
|
49
52
|
end
|
|
50
53
|
|
|
51
54
|
def update(scope:, id:, record:)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
@mutex.synchronize do
|
|
56
|
+
existing = @records[id]
|
|
57
|
+
raise Engram::Error, "no memory with id #{id.inspect} in scope #{scope.inspect}" unless existing&.scope == scope
|
|
58
|
+
raise Engram::Error, "cannot move memory across scopes" unless record.scope == scope
|
|
59
|
+
|
|
60
|
+
record.id = id
|
|
61
|
+
@records[id] = record
|
|
62
|
+
end
|
|
58
63
|
end
|
|
59
64
|
|
|
60
65
|
def delete(scope:, id:)
|
|
61
|
-
|
|
66
|
+
@mutex.synchronize do
|
|
67
|
+
return 0 unless @records[id]&.scope == scope
|
|
62
68
|
|
|
63
|
-
|
|
64
|
-
|
|
69
|
+
@records.delete(id)
|
|
70
|
+
1
|
|
71
|
+
end
|
|
65
72
|
end
|
|
66
73
|
|
|
67
74
|
def touch(scope:, id:, at: Time.now)
|
|
68
|
-
|
|
69
|
-
|
|
75
|
+
@mutex.synchronize do
|
|
76
|
+
record = @records[id]
|
|
77
|
+
return 0 unless record&.scope == scope
|
|
78
|
+
|
|
79
|
+
record.last_accessed_at = at
|
|
80
|
+
1
|
|
81
|
+
end
|
|
82
|
+
end
|
|
70
83
|
|
|
71
|
-
|
|
72
|
-
|
|
84
|
+
def expired_ids(scope:, at:, limit:, after_id: nil)
|
|
85
|
+
# IDs follow insertion order; the hash retains it across updates/deletes.
|
|
86
|
+
@mutex.synchronize do
|
|
87
|
+
@records.each_value.lazy
|
|
88
|
+
.select { |record| record.scope == scope && (after_id.nil? || record.id > after_id) && record.expired?(at: at) }
|
|
89
|
+
.map(&:id).take(limit).force
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def delete_expired(scope:, ids:, at:)
|
|
94
|
+
@mutex.synchronize do
|
|
95
|
+
ids.uniq.count do |id|
|
|
96
|
+
record = @records[id]
|
|
97
|
+
next false unless record&.scope == scope && record.expired?(at: at)
|
|
98
|
+
|
|
99
|
+
@records.delete(id)
|
|
100
|
+
true
|
|
101
|
+
end
|
|
102
|
+
end
|
|
73
103
|
end
|
|
74
104
|
|
|
75
105
|
def clear
|
|
76
|
-
@
|
|
77
|
-
|
|
106
|
+
@mutex.synchronize do
|
|
107
|
+
@records.clear
|
|
108
|
+
@sequence = 0
|
|
109
|
+
end
|
|
78
110
|
end
|
|
79
111
|
|
|
80
112
|
private
|
|
@@ -18,6 +18,7 @@ module Engram
|
|
|
18
18
|
validate_scope!(record.scope)
|
|
19
19
|
|
|
20
20
|
row = model.create!(
|
|
21
|
+
**expiry_attributes(record),
|
|
21
22
|
content: record.content,
|
|
22
23
|
scope: record.scope,
|
|
23
24
|
kind: record.kind.to_s,
|
|
@@ -31,6 +32,10 @@ module Engram
|
|
|
31
32
|
def search(embedding:, scope:, limit:, kinds: nil, embedding_metadata: nil)
|
|
32
33
|
Engram::EmbeddingMetadata.validate_query!(embedding, embedding_metadata)
|
|
33
34
|
query = model.where(scope: scope)
|
|
35
|
+
if expiry_column?
|
|
36
|
+
expiry = model.arel_table[:expires_at]
|
|
37
|
+
query = query.where(expiry.eq(nil).or(expiry.gt(Time.now)))
|
|
38
|
+
end
|
|
34
39
|
normalized_kinds = normalize_kinds(kinds)
|
|
35
40
|
query = query.where(kind: normalized_kinds) if normalized_kinds
|
|
36
41
|
|
|
@@ -70,6 +75,7 @@ module Engram
|
|
|
70
75
|
model.transaction do
|
|
71
76
|
row = model.lock.find_by!(id: id, scope: scope)
|
|
72
77
|
row.update!(
|
|
78
|
+
**expiry_attributes(record),
|
|
73
79
|
content: record.content,
|
|
74
80
|
kind: record.kind.to_s,
|
|
75
81
|
importance: record.importance,
|
|
@@ -90,8 +96,37 @@ module Engram
|
|
|
90
96
|
model.where(id: id, scope: scope).update_all(last_accessed_at: at)
|
|
91
97
|
end
|
|
92
98
|
|
|
99
|
+
def expired_ids(scope:, at:, limit:, after_id: nil)
|
|
100
|
+
return [] unless expiry_column?
|
|
101
|
+
|
|
102
|
+
query = expired_scope(scope, at)
|
|
103
|
+
query = query.where(model.arel_table[:id].gt(after_id)) unless after_id.nil?
|
|
104
|
+
query.order(:id).limit(limit).pluck(:id)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def delete_expired(scope:, ids:, at:)
|
|
108
|
+
return 0 if ids.empty? || !expiry_column?
|
|
109
|
+
|
|
110
|
+
expired_scope(scope, at).where(id: ids).delete_all
|
|
111
|
+
end
|
|
112
|
+
|
|
93
113
|
private
|
|
94
114
|
|
|
115
|
+
def expired_scope(scope, at)
|
|
116
|
+
model.where(scope: scope).where(model.arel_table[:expires_at].lteq(at))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def expiry_column?
|
|
120
|
+
model.column_names.include?("expires_at")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def expiry_attributes(record)
|
|
124
|
+
return {expires_at: record.expires_at} if expiry_column?
|
|
125
|
+
return {} if record.expires_at.nil?
|
|
126
|
+
|
|
127
|
+
raise Engram::Error, "PgvectorStore needs a nullable expires_at datetime column to store expiring memories"
|
|
128
|
+
end
|
|
129
|
+
|
|
95
130
|
def validate_scope!(scope)
|
|
96
131
|
raise Engram::Error, "memory scope cannot be nil" if scope.nil?
|
|
97
132
|
end
|
|
@@ -124,7 +159,8 @@ module Engram
|
|
|
124
159
|
importance: row.importance || 1.0,
|
|
125
160
|
metadata: row.metadata || {},
|
|
126
161
|
created_at: row.created_at,
|
|
127
|
-
last_accessed_at: row.try(:last_accessed_at)
|
|
162
|
+
last_accessed_at: row.try(:last_accessed_at),
|
|
163
|
+
expires_at: row.try(:expires_at)
|
|
128
164
|
)
|
|
129
165
|
end
|
|
130
166
|
|
data/lib/engram/configuration.rb
CHANGED
|
@@ -8,13 +8,16 @@ module Engram
|
|
|
8
8
|
attr_accessor :store, :embedder, :completion, :default_limit,
|
|
9
9
|
:consolidator, :extraction_min_confidence, :processed_turns,
|
|
10
10
|
:importance_weight, :recency_weight, :recency_halflife, :touch_on_recall,
|
|
11
|
-
:persistence_policy, :before_persist, :instrumentation_scope_identifier
|
|
11
|
+
:persistence_policy, :before_persist, :instrumentation_scope_identifier,
|
|
12
|
+
:recall_min_similarity, :injection_max_bytes
|
|
12
13
|
|
|
13
14
|
def initialize
|
|
14
15
|
@store = Adapters::InMemoryStore.new
|
|
15
16
|
@embedder = Adapters::NullEmbedder.new
|
|
16
17
|
@completion = nil # required for observe (extract/consolidate); nil until configured
|
|
17
18
|
@default_limit = 5
|
|
19
|
+
@recall_min_similarity = nil # opt-in cosine floor, before importance/recency ranking
|
|
20
|
+
@injection_max_bytes = nil # cap the complete appended memory block, not the host prompt
|
|
18
21
|
@consolidator = :heuristic # :heuristic (deterministic) or :llm (LLM-as-judge)
|
|
19
22
|
@extraction_min_confidence = 0.5
|
|
20
23
|
@processed_turns = Adapters::InMemoryProcessedTurns.new # idempotency for observe
|
|
@@ -9,21 +9,27 @@ module Engram
|
|
|
9
9
|
# chat = Engram.with_memory(RubyLLM.chat, memory: current_user.memory)
|
|
10
10
|
# chat.ask("why am I rate limited?") # recall + inject happen automatically
|
|
11
11
|
class MemoryChat
|
|
12
|
-
def initialize(chat, memory:, limit: Engram.config.default_limit
|
|
12
|
+
def initialize(chat, memory:, limit: Engram.config.default_limit, kinds: nil,
|
|
13
|
+
min_similarity: Engram.config.recall_min_similarity, max_bytes: Engram.config.injection_max_bytes)
|
|
13
14
|
@chat = chat
|
|
14
15
|
@memory = memory
|
|
15
16
|
@limit = limit
|
|
17
|
+
@kinds = kinds
|
|
18
|
+
@min_similarity = min_similarity
|
|
19
|
+
@max_bytes = max_bytes
|
|
16
20
|
end
|
|
17
21
|
|
|
18
|
-
def ask(message, **opts)
|
|
19
|
-
augmented = @memory.inject_into(message.to_s, query: message.to_s, limit: @limit
|
|
20
|
-
|
|
22
|
+
def ask(message, **opts, &block)
|
|
23
|
+
augmented = @memory.inject_into(message.to_s, query: message.to_s, limit: @limit,
|
|
24
|
+
kinds: @kinds, min_similarity: @min_similarity, max_bytes: @max_bytes)
|
|
25
|
+
@chat.ask(augmented, **opts, &block)
|
|
21
26
|
end
|
|
22
27
|
|
|
23
28
|
def method_missing(name, *args, **kwargs, &block)
|
|
24
29
|
return super unless @chat.respond_to?(name)
|
|
25
30
|
|
|
26
|
-
@chat.public_send(name, *args, **kwargs, &block)
|
|
31
|
+
result = @chat.public_send(name, *args, **kwargs, &block)
|
|
32
|
+
result.equal?(@chat) ? self : result
|
|
27
33
|
end
|
|
28
34
|
|
|
29
35
|
def respond_to_missing?(name, include_private = false)
|
|
@@ -34,7 +40,9 @@ module Engram
|
|
|
34
40
|
end
|
|
35
41
|
|
|
36
42
|
# Convenience entrypoint.
|
|
37
|
-
def self.with_memory(chat, memory:, limit: config.default_limit
|
|
38
|
-
|
|
43
|
+
def self.with_memory(chat, memory:, limit: config.default_limit, kinds: nil,
|
|
44
|
+
min_similarity: config.recall_min_similarity, max_bytes: config.injection_max_bytes)
|
|
45
|
+
Integrations::RubyLLM::MemoryChat.new(chat, memory: memory, limit: limit, kinds: kinds,
|
|
46
|
+
min_similarity: min_similarity, max_bytes: max_bytes)
|
|
39
47
|
end
|
|
40
48
|
end
|
|
@@ -29,7 +29,8 @@ module Engram
|
|
|
29
29
|
kind: [Symbol],
|
|
30
30
|
importance: [Integer, Float],
|
|
31
31
|
created_at: [Time],
|
|
32
|
-
last_accessed_at: [NilClass, Time]
|
|
32
|
+
last_accessed_at: [NilClass, Time],
|
|
33
|
+
expires_at: [NilClass, Time]
|
|
33
34
|
}.freeze
|
|
34
35
|
|
|
35
36
|
Snapshot = Struct.new(:collection, :collection_state, :records)
|
data/lib/engram/memory.rb
CHANGED
|
@@ -14,23 +14,23 @@ module Engram
|
|
|
14
14
|
|
|
15
15
|
# Persist a memory record of the given kind. Returns nil when the configured
|
|
16
16
|
# persistence policy rejects the record.
|
|
17
|
-
def add(content, kind: :fact, importance: 1.0, metadata: {})
|
|
17
|
+
def add(content, kind: :fact, importance: 1.0, metadata: {}, expires_at: nil)
|
|
18
18
|
Engram::Instrumentation.instrument("add", Engram::Instrumentation.payload(scope: scope, store: @store, kind: kind)) do
|
|
19
|
-
embedding = @embedder.embed(content)
|
|
20
19
|
record = Record.new(
|
|
21
20
|
content: content,
|
|
22
21
|
scope: scope,
|
|
23
|
-
embedding: embedding,
|
|
24
22
|
kind: kind,
|
|
25
23
|
importance: importance,
|
|
26
|
-
metadata: metadata
|
|
24
|
+
metadata: metadata,
|
|
25
|
+
expires_at: expires_at
|
|
27
26
|
)
|
|
28
|
-
persist(record)
|
|
27
|
+
persist(record.with(embedding: @embedder.embed(content)))
|
|
29
28
|
end
|
|
30
29
|
end
|
|
31
30
|
|
|
32
31
|
# Return the most relevant memories for a query.
|
|
33
|
-
def recall(query, limit: Engram.config.default_limit, kinds: nil
|
|
32
|
+
def recall(query, limit: Engram.config.default_limit, kinds: nil,
|
|
33
|
+
min_similarity: Engram.config.recall_min_similarity)
|
|
34
34
|
UseCases::Recall.new(
|
|
35
35
|
store: @store,
|
|
36
36
|
embedder: @embedder,
|
|
@@ -38,13 +38,15 @@ module Engram
|
|
|
38
38
|
recency_weight: Engram.config.recency_weight,
|
|
39
39
|
recency_halflife: Engram.config.recency_halflife,
|
|
40
40
|
touch: Engram.config.touch_on_recall
|
|
41
|
-
).call(query, scope: scope, limit: limit, kinds: kinds)
|
|
41
|
+
).call(query, scope: scope, limit: limit, kinds: kinds, min_similarity: min_similarity)
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
# Recall, then inject into a prompt string.
|
|
45
|
-
def inject_into(prompt, query:, limit: Engram.config.default_limit, kinds: nil
|
|
46
|
-
|
|
47
|
-
UseCases::Inject.
|
|
45
|
+
def inject_into(prompt, query:, limit: Engram.config.default_limit, kinds: nil,
|
|
46
|
+
min_similarity: Engram.config.recall_min_similarity, max_bytes: Engram.config.injection_max_bytes)
|
|
47
|
+
UseCases::Inject.validate_max_bytes!(max_bytes)
|
|
48
|
+
memories = (max_bytes == 0) ? [] : recall(query, limit: limit, kinds: kinds, min_similarity: min_similarity)
|
|
49
|
+
UseCases::Inject.new.call(prompt: prompt, memories: memories, max_bytes: max_bytes)
|
|
48
50
|
end
|
|
49
51
|
|
|
50
52
|
# Derive memories from a conversation turn and consolidate them (v0.2).
|
|
@@ -86,6 +88,18 @@ module Engram
|
|
|
86
88
|
@store.all(scope: scope)
|
|
87
89
|
end
|
|
88
90
|
|
|
91
|
+
# Delete one memory in this scope. Returns 1 when deleted, or 0 when missing.
|
|
92
|
+
# Explicit deletion bypasses extraction, consolidation, and persistence hooks.
|
|
93
|
+
def forget(id:)
|
|
94
|
+
valid_id = id.instance_of?(Integer) || (id.instance_of?(String) && id.valid_encoding? && !id.strip.empty?)
|
|
95
|
+
raise ArgumentError, "id must be an Integer or a non-empty String" unless valid_id
|
|
96
|
+
|
|
97
|
+
payload = Engram::Instrumentation.payload(scope: scope, store: @store)
|
|
98
|
+
Engram::Instrumentation.instrument("forget", payload) do
|
|
99
|
+
payload[:deleted_count] = @store.delete(scope: scope, id: id)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
89
103
|
# Recompute embeddings (and embedding metadata) for memories in the scope.
|
|
90
104
|
# When `stale_only` is true, only records whose current metadata does not match the
|
|
91
105
|
# active embedder are rebuilt.
|
|
@@ -104,6 +118,13 @@ module Engram
|
|
|
104
118
|
.call(scope: scope, older_than: older_than, min_importance: min_importance)
|
|
105
119
|
end
|
|
106
120
|
|
|
121
|
+
# Delete expired records without loading their content or embeddings.
|
|
122
|
+
# Returns matched/deleted counts; dry runs only count eligible records.
|
|
123
|
+
def forget_expired(batch_size: 100, dry_run: false)
|
|
124
|
+
UseCases::ForgetExpired.new(store: @store)
|
|
125
|
+
.call(scope: scope, batch_size: batch_size, dry_run: dry_run)
|
|
126
|
+
end
|
|
127
|
+
|
|
107
128
|
# Return the memories in this scope whose provenance references the exact host
|
|
108
129
|
# source. `source_id` and `source_type` must each be non-blank Strings and are
|
|
109
130
|
# matched exactly, without normalization. Source IDs are references, not an
|
|
@@ -12,12 +12,13 @@ module Engram
|
|
|
12
12
|
|
|
13
13
|
# Return up to `limit` Records in `scope` nearest to `embedding`,
|
|
14
14
|
# ordered most-relevant first. When `kinds` is provided, only records with
|
|
15
|
-
# those canonical memory kinds are eligible.
|
|
15
|
+
# those canonical memory kinds are eligible. Exclude expired records before
|
|
16
|
+
# ordering and limiting; expires_at <= Time.now is expired, nil never expires.
|
|
16
17
|
def search(embedding:, scope:, limit:, kinds: nil, embedding_metadata: nil)
|
|
17
18
|
raise NotImplementedError, "#{self.class} must implement #search"
|
|
18
19
|
end
|
|
19
20
|
|
|
20
|
-
# All Records for a scope
|
|
21
|
+
# All Records for a scope, including expired records (inspection/maintenance).
|
|
21
22
|
# Supports optional `limit` and `offset` for batching large sweeps.
|
|
22
23
|
# Returned records are sorted in stable `id` order when batching is used.
|
|
23
24
|
# Use `after_id` for keyset pagination.
|
|
@@ -49,6 +50,19 @@ module Engram
|
|
|
49
50
|
raise NotImplementedError, "#{self.class} must implement #delete"
|
|
50
51
|
end
|
|
51
52
|
|
|
53
|
+
# Optional expiry maintenance: up to `limit` IDs with expires_at <= at,
|
|
54
|
+
# ordered by ID, strictly after after_id. Never includes nil expiry.
|
|
55
|
+
def expired_ids(scope:, at:, limit:, after_id: nil)
|
|
56
|
+
raise NotImplementedError, "#{self.class} does not implement #expired_ids"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Optional expiry maintenance: delete only the requested IDs in scope that
|
|
60
|
+
# are still expired at the same cutoff. Recheck expiry atomically at deletion
|
|
61
|
+
# so a concurrent deadline extension is preserved. Returns the deleted count.
|
|
62
|
+
def delete_expired(scope:, ids:, at:)
|
|
63
|
+
raise NotImplementedError, "#{self.class} does not implement #delete_expired"
|
|
64
|
+
end
|
|
65
|
+
|
|
52
66
|
# Update the last-accessed timestamp of a memory. Used by recency-aware recall.
|
|
53
67
|
# Returns the number of affected rows: 1 when touched, 0 when the scoped record
|
|
54
68
|
# does not exist.
|
data/lib/engram/rails/tasks.rake
CHANGED
|
@@ -3,6 +3,21 @@
|
|
|
3
3
|
# Loaded by the Railtie in Rails apps (where :environment boots the app and its
|
|
4
4
|
# initializers) and by the gem's own Rakefile (which defines a no-op :environment).
|
|
5
5
|
namespace :engram do
|
|
6
|
+
desc "Delete expired memories in a scope. Usage: bundle exec rake 'engram:forget_expired[user:1]'"
|
|
7
|
+
task :forget_expired, [:scope] => :environment do |_, args|
|
|
8
|
+
scope = args[:scope]
|
|
9
|
+
raise ArgumentError, "forget_expired requires a scope argument" if scope.to_s.empty?
|
|
10
|
+
|
|
11
|
+
dry_run = case ENV.fetch("DRY_RUN", "false").downcase
|
|
12
|
+
when "true", "1", "yes" then true
|
|
13
|
+
when "false", "0", "no" then false
|
|
14
|
+
else raise ArgumentError, "DRY_RUN must be true or false"
|
|
15
|
+
end
|
|
16
|
+
batch_size = Integer(ENV.fetch("BATCH_SIZE", "100"))
|
|
17
|
+
result = Engram::Memory.new(scope: scope).forget_expired(batch_size: batch_size, dry_run: dry_run)
|
|
18
|
+
puts "matched=#{result[:matched]} deleted=#{result[:deleted]} dry_run=#{result[:dry_run]}"
|
|
19
|
+
end
|
|
20
|
+
|
|
6
21
|
desc "Rebuild embeddings in a memory scope. Usage: bundle exec rake 'engram:rebuild_embeddings[user:1]'"
|
|
7
22
|
task :rebuild_embeddings, [:scope] => :environment do |_, args|
|
|
8
23
|
scope = args[:scope]
|
data/lib/engram/railtie.rb
CHANGED
|
@@ -15,9 +15,7 @@ module Engram
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
initializer "engram.active_job" do
|
|
18
|
-
|
|
19
|
-
require "engram/rails/observe_job"
|
|
20
|
-
end
|
|
18
|
+
Engram.autoload :ObserveJob, "engram/rails/observe_job" if defined?(::ActiveJob::Base)
|
|
21
19
|
end
|
|
22
20
|
|
|
23
21
|
rake_tasks do
|
data/lib/engram/record.rb
CHANGED
|
@@ -9,15 +9,18 @@ module Engram
|
|
|
9
9
|
# `semantic` kind is normalized to `fact` for compatibility with pre-1.0 records.
|
|
10
10
|
class Record
|
|
11
11
|
STATE_READERS = %i[
|
|
12
|
-
id content scope embedding kind importance metadata created_at last_accessed_at
|
|
12
|
+
id content scope embedding kind importance metadata created_at last_accessed_at expires_at
|
|
13
13
|
].freeze
|
|
14
14
|
|
|
15
15
|
attr_accessor :id, :last_accessed_at
|
|
16
16
|
attr_reader :content, :embedding, :scope, :kind, :importance, :metadata,
|
|
17
|
-
:created_at
|
|
17
|
+
:created_at, :expires_at
|
|
18
18
|
|
|
19
19
|
def initialize(content:, scope:, id: nil, embedding: nil, kind: :fact,
|
|
20
|
-
importance: 1.0, metadata: {}, created_at: nil, last_accessed_at: nil)
|
|
20
|
+
importance: 1.0, metadata: {}, created_at: nil, last_accessed_at: nil, expires_at: nil)
|
|
21
|
+
unless expires_at.nil? || expires_at.is_a?(Time)
|
|
22
|
+
raise ArgumentError, "expires_at must be a Time or nil"
|
|
23
|
+
end
|
|
21
24
|
@id = id
|
|
22
25
|
@content = content
|
|
23
26
|
@scope = scope
|
|
@@ -27,12 +30,17 @@ module Engram
|
|
|
27
30
|
@metadata = metadata
|
|
28
31
|
@created_at = created_at || Time.now
|
|
29
32
|
@last_accessed_at = last_accessed_at
|
|
33
|
+
@expires_at = expires_at&.getutc
|
|
30
34
|
end
|
|
31
35
|
|
|
32
36
|
def with(**attributes)
|
|
33
37
|
self.class.new(**to_h.merge(attributes))
|
|
34
38
|
end
|
|
35
39
|
|
|
40
|
+
def expired?(at: Time.now)
|
|
41
|
+
!expires_at.nil? && expires_at <= at
|
|
42
|
+
end
|
|
43
|
+
|
|
36
44
|
# Return structured supporting-source metadata when this record carries a
|
|
37
45
|
# provenance schema understood by the installed Engram version.
|
|
38
46
|
# Legacy, malformed, and future-schema records remain readable and return nil.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Engram
|
|
4
|
+
module UseCases
|
|
5
|
+
# Deletes expired memories in bounded batches using one cutoff for the whole run.
|
|
6
|
+
class ForgetExpired
|
|
7
|
+
def initialize(store:)
|
|
8
|
+
@store = store
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(scope:, batch_size: 100, dry_run: false, now: Time.now)
|
|
12
|
+
unless batch_size.is_a?(Integer) && batch_size.positive?
|
|
13
|
+
raise ArgumentError, "batch_size must be a positive integer"
|
|
14
|
+
end
|
|
15
|
+
unless dry_run.equal?(true) || dry_run.equal?(false)
|
|
16
|
+
raise ArgumentError, "dry_run must be true or false"
|
|
17
|
+
end
|
|
18
|
+
unless @store.respond_to?(:expired_ids) && @store.respond_to?(:delete_expired)
|
|
19
|
+
raise Engram::Error, "forget_expired requires a store implementing expired_ids and delete_expired"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
counts = {matched: 0, deleted: 0, dry_run: dry_run}
|
|
23
|
+
payload = Engram::Instrumentation.payload(scope: scope, store: @store, **counts)
|
|
24
|
+
Engram::Instrumentation.instrument("forget_expired", payload) do
|
|
25
|
+
after_id = nil
|
|
26
|
+
loop do
|
|
27
|
+
ids = @store.expired_ids(scope: scope, at: now, limit: batch_size, after_id: after_id)
|
|
28
|
+
break if ids.empty?
|
|
29
|
+
raise Engram::Error, "expired_ids must advance its cursor" if ids.last == after_id
|
|
30
|
+
|
|
31
|
+
counts[:matched] += ids.length
|
|
32
|
+
counts[:deleted] += @store.delete_expired(scope: scope, ids: ids, at: now) unless dry_run
|
|
33
|
+
payload.merge!(counts)
|
|
34
|
+
after_id = ids.last
|
|
35
|
+
end
|
|
36
|
+
counts
|
|
37
|
+
end
|
|
38
|
+
rescue NotImplementedError
|
|
39
|
+
raise Engram::Error, "forget_expired requires a store implementing expired_ids and delete_expired"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -12,14 +12,42 @@ module Engram
|
|
|
12
12
|
@header = header
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
def self.validate_max_bytes!(value)
|
|
16
|
+
return if value.nil? || (value.is_a?(Integer) && value >= 0)
|
|
17
|
+
|
|
18
|
+
raise ArgumentError, "max_bytes must be a non-negative integer, or nil"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The budget includes the header, delimiters, escaping, and separators, but
|
|
22
|
+
# excludes the original prompt. Skip whole memories that do not fit.
|
|
23
|
+
def call(prompt:, memories:, max_bytes: nil)
|
|
24
|
+
self.class.validate_max_bytes!(max_bytes)
|
|
25
|
+
payload = {memory_count: memories&.size.to_i, injected_count: 0,
|
|
26
|
+
skipped_count: memories&.size.to_i, injected_bytes: 0, max_bytes: max_bytes}.compact
|
|
18
27
|
Engram::Instrumentation.instrument("inject", payload) do
|
|
19
|
-
next prompt if memories.nil? || memories.empty?
|
|
28
|
+
next prompt if memories.nil? || memories.empty? || max_bytes == 0
|
|
29
|
+
|
|
30
|
+
prefix = "\n\n#{@header}:\n<engram-memories>\n"
|
|
31
|
+
suffix = "\n</engram-memories>"
|
|
32
|
+
bytes = prefix.bytesize + suffix.bytesize
|
|
33
|
+
lines = []
|
|
34
|
+
now = Time.now
|
|
35
|
+
memories.each do |memory|
|
|
36
|
+
next if memory.expired?(at: now)
|
|
37
|
+
|
|
38
|
+
line = render_memory(memory)
|
|
39
|
+
added_bytes = line.bytesize + (lines.empty? ? 0 : 1)
|
|
40
|
+
next if max_bytes && bytes + added_bytes > max_bytes
|
|
41
|
+
|
|
42
|
+
lines << line
|
|
43
|
+
bytes += added_bytes
|
|
44
|
+
end
|
|
45
|
+
next prompt if lines.empty?
|
|
20
46
|
|
|
21
|
-
|
|
22
|
-
|
|
47
|
+
payload[:injected_count] = lines.size
|
|
48
|
+
payload[:skipped_count] -= lines.size
|
|
49
|
+
payload[:injected_bytes] = bytes
|
|
50
|
+
"#{prompt}#{prefix}#{lines.join("\n")}#{suffix}"
|
|
23
51
|
end
|
|
24
52
|
end
|
|
25
53
|
|
|
@@ -24,17 +24,27 @@ module Engram
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
# Returns Array<Record>, most relevant first.
|
|
27
|
-
def call(query, scope:, limit: Engram.config.default_limit, kinds: nil)
|
|
27
|
+
def call(query, scope:, limit: Engram.config.default_limit, kinds: nil, min_similarity: nil)
|
|
28
28
|
raise ArgumentError, "query must be a non-empty string" if query.to_s.strip.empty?
|
|
29
|
+
unless limit.is_a?(Integer) && limit >= 0
|
|
30
|
+
raise ArgumentError, "limit must be a non-negative integer"
|
|
31
|
+
end
|
|
32
|
+
validate_min_similarity!(min_similarity)
|
|
29
33
|
|
|
30
34
|
payload = Engram::Instrumentation.payload(
|
|
31
35
|
scope: scope,
|
|
32
36
|
store: @store,
|
|
33
37
|
limit: limit,
|
|
34
38
|
kinds: Array(kinds).map(&:to_s),
|
|
35
|
-
reranking: reranking
|
|
39
|
+
reranking: reranking?,
|
|
40
|
+
min_similarity: min_similarity,
|
|
41
|
+
candidate_count: 0,
|
|
42
|
+
filtered_count: 0,
|
|
43
|
+
result_count: 0
|
|
36
44
|
)
|
|
37
45
|
Engram::Instrumentation.instrument("recall", payload) do
|
|
46
|
+
next [] if limit.zero?
|
|
47
|
+
|
|
38
48
|
embedding = @embedder.embed(query)
|
|
39
49
|
embedding_metadata = Engram::EmbeddingMetadata.for_embedder(@embedder, embedding: embedding)
|
|
40
50
|
pool_limit = reranking? ? limit * @pool_factor : limit
|
|
@@ -47,16 +57,43 @@ module Engram
|
|
|
47
57
|
kinds: kinds
|
|
48
58
|
)
|
|
49
59
|
|
|
50
|
-
|
|
60
|
+
now = Time.now
|
|
61
|
+
candidates = pool.select do |record|
|
|
62
|
+
!record.expired?(at: now) &&
|
|
63
|
+
(min_similarity.nil? || meets_similarity?(record.embedding, embedding, min_similarity))
|
|
64
|
+
end
|
|
65
|
+
results = (reranking? ? rerank(candidates, embedding) : candidates).first(limit)
|
|
51
66
|
touch(results, scope) if @touch
|
|
52
67
|
payload[:result_count] = results.size
|
|
53
68
|
payload[:candidate_count] = pool.size
|
|
69
|
+
payload[:filtered_count] = pool.size - candidates.size
|
|
54
70
|
results
|
|
55
71
|
end
|
|
56
72
|
end
|
|
57
73
|
|
|
58
74
|
private
|
|
59
75
|
|
|
76
|
+
def validate_min_similarity!(value)
|
|
77
|
+
return if value.nil?
|
|
78
|
+
return if value.is_a?(Numeric) && value.real? && value.finite? && value.between?(-1, 1)
|
|
79
|
+
|
|
80
|
+
raise ArgumentError, "min_similarity must be a finite number between -1 and 1, or nil"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def meets_similarity?(embedding, query_embedding, minimum)
|
|
84
|
+
return false unless comparable_vector?(embedding) && comparable_vector?(query_embedding)
|
|
85
|
+
return false unless embedding.length == query_embedding.length
|
|
86
|
+
|
|
87
|
+
similarity = Engram::Math.cosine_similarity(query_embedding, embedding)
|
|
88
|
+
similarity.finite? && similarity.clamp(-1.0, 1.0) >= minimum
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def comparable_vector?(vector)
|
|
92
|
+
vector.is_a?(Array) && !vector.empty? &&
|
|
93
|
+
vector.all? { |value| value.is_a?(Numeric) && value.real? && value.finite? } &&
|
|
94
|
+
vector.any? { |value| !value.zero? }
|
|
95
|
+
end
|
|
96
|
+
|
|
60
97
|
def reranking?
|
|
61
98
|
!@importance_weight.zero? || !@recency_weight.zero?
|
|
62
99
|
end
|
data/lib/engram/version.rb
CHANGED
data/lib/engram.rb
CHANGED
|
@@ -31,6 +31,7 @@ require_relative "engram/use_cases/recall"
|
|
|
31
31
|
require_relative "engram/use_cases/inject"
|
|
32
32
|
require_relative "engram/use_cases/observe"
|
|
33
33
|
require_relative "engram/use_cases/forget"
|
|
34
|
+
require_relative "engram/use_cases/forget_expired"
|
|
34
35
|
require_relative "engram/use_cases/rebuild_embeddings"
|
|
35
36
|
require_relative "engram/use_cases/source_impact"
|
|
36
37
|
require_relative "engram/use_cases/grounding_report"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module Engram
|
|
7
|
+
module Generators
|
|
8
|
+
# Adds expiry to a table created by Engram 0.7 or earlier.
|
|
9
|
+
class ExpiryGenerator < ::Rails::Generators::Base
|
|
10
|
+
include ::Rails::Generators::Migration
|
|
11
|
+
|
|
12
|
+
source_root File.expand_path("templates", __dir__)
|
|
13
|
+
|
|
14
|
+
def create_migration_file
|
|
15
|
+
migration_template "add_expiry_to_engram_memories.rb.tt",
|
|
16
|
+
"db/migrate/add_expiry_to_engram_memories.rb"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.next_migration_number(dir)
|
|
20
|
+
::ActiveRecord::Generators::Base.next_migration_number(dir)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def migration_version
|
|
26
|
+
"#{::ActiveRecord::VERSION::MAJOR}.#{::ActiveRecord::VERSION::MINOR}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddExpiryToEngramMemories < ActiveRecord::Migration[<%= migration_version %>]
|
|
4
|
+
disable_ddl_transaction!
|
|
5
|
+
|
|
6
|
+
def change
|
|
7
|
+
add_column :engram_memories, :expires_at, :datetime
|
|
8
|
+
add_index :engram_memories, [:scope, :expires_at],
|
|
9
|
+
name: "index_engram_memories_on_scope_and_expiry",
|
|
10
|
+
where: "expires_at IS NOT NULL", algorithm: :concurrently
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -12,11 +12,15 @@ class CreateEngramMemories < ActiveRecord::Migration[<%= migration_version %>]
|
|
|
12
12
|
t.jsonb :metadata, null: false, default: {}
|
|
13
13
|
t.vector :embedding, limit: <%= dimensions %>
|
|
14
14
|
t.datetime :last_accessed_at
|
|
15
|
+
t.datetime :expires_at
|
|
15
16
|
|
|
16
17
|
t.timestamps
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
add_index :engram_memories, :scope
|
|
21
|
+
add_index :engram_memories, [:scope, :expires_at],
|
|
22
|
+
name: "index_engram_memories_on_scope_and_expiry",
|
|
23
|
+
where: "expires_at IS NOT NULL"
|
|
20
24
|
|
|
21
25
|
# For larger datasets, choose one approximate vector index after backfilling data.
|
|
22
26
|
# HNSW is a strong default for read-heavy recall workloads with frequent inserts.
|
|
@@ -9,4 +9,9 @@ Engram.configure do |config|
|
|
|
9
9
|
|
|
10
10
|
# How many memories to recall by default.
|
|
11
11
|
config.default_limit = 5
|
|
12
|
+
|
|
13
|
+
# Minimum cosine similarity. Tune for your embedding model.
|
|
14
|
+
# config.recall_min_similarity = 0.5
|
|
15
|
+
# Cap the appended memory block in bytes, including escaped text and delimiters.
|
|
16
|
+
# config.injection_max_bytes = 8_000
|
|
12
17
|
end
|
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.8.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-09-15 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
|
|
@@ -65,6 +65,7 @@ files:
|
|
|
65
65
|
- lib/engram/reserved_metadata.rb
|
|
66
66
|
- lib/engram/turn_digest.rb
|
|
67
67
|
- lib/engram/use_cases/forget.rb
|
|
68
|
+
- lib/engram/use_cases/forget_expired.rb
|
|
68
69
|
- lib/engram/use_cases/grounding_report.rb
|
|
69
70
|
- lib/engram/use_cases/inject.rb
|
|
70
71
|
- lib/engram/use_cases/observe.rb
|
|
@@ -72,7 +73,9 @@ files:
|
|
|
72
73
|
- lib/engram/use_cases/recall.rb
|
|
73
74
|
- lib/engram/use_cases/source_impact.rb
|
|
74
75
|
- lib/engram/version.rb
|
|
76
|
+
- lib/generators/engram/expiry_generator.rb
|
|
75
77
|
- lib/generators/engram/install_generator.rb
|
|
78
|
+
- lib/generators/engram/templates/add_expiry_to_engram_memories.rb.tt
|
|
76
79
|
- lib/generators/engram/templates/create_engram_memories.rb.tt
|
|
77
80
|
- lib/generators/engram/templates/initializer.rb.tt
|
|
78
81
|
- lib/generators/engram/templates/memory_record.rb.tt
|