engram 0.7.0 → 0.9.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 +44 -0
- data/README.md +158 -2
- data/lib/engram/adapters/in_memory_store.rb +71 -22
- data/lib/engram/adapters/pgvector_store.rb +48 -1
- data/lib/engram/internal/candidate_integrity.rb +2 -1
- data/lib/engram/memory.rb +56 -11
- data/lib/engram/persistence.rb +16 -6
- data/lib/engram/ports/extractor.rb +3 -4
- data/lib/engram/ports/memory_store.rb +28 -2
- data/lib/engram/rails/has_memory.rb +5 -0
- data/lib/engram/rails/tasks.rake +30 -0
- data/lib/engram/record.rb +11 -3
- data/lib/engram/use_cases/each_memory.rb +56 -0
- data/lib/engram/use_cases/forget_all.rb +59 -0
- data/lib/engram/use_cases/forget_expired.rb +43 -0
- data/lib/engram/use_cases/inject.rb +3 -0
- data/lib/engram/use_cases/recall.rb +4 -4
- data/lib/engram/use_cases/update.rb +185 -0
- data/lib/engram/version.rb +1 -1
- data/lib/engram.rb +7 -0
- data/lib/generators/engram/expiry_generator.rb +30 -0
- data/lib/generators/engram/install_generator.rb +6 -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 +2 -1
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 50765b71eaace1c3abc16581e67c2466bf992fa023b082fe29d4b2c889cfb9d9
|
|
4
|
+
data.tar.gz: c439b5c3f34b9c91b8e1b7099b015c9b05eda5db93edc66de2bd913370c19303
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e862092cd85d8501d437d18a9c31dfe039809196c927c685235ee42bc32be629df3fda5b3f3934fdc92c6f2a64e110eb30aecd1b2e8889178a1c97a2295e5564
|
|
7
|
+
data.tar.gz: fddf7838f9396078287d58d67f25cb1b182c355c21b32b2937b4715e8ed63541305927c8b48b070dee426e3f3196330572a11c02cf7f17a94ed76b2d1f7985dd
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,50 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.9.0] - 2026-10-12
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `Memory#update(id:, ...)` changes the given attributes of a memory in the current scope,
|
|
13
|
+
keeping its id and timestamps. It returns the stored record, `nil` when `before_persist` or
|
|
14
|
+
the policy rejects the edit, and raises `Engram::MemoryNotFoundError` for an unknown id.
|
|
15
|
+
Content changes re-embed the memory and remove its provenance. Emits `update.engram`.
|
|
16
|
+
- `Memory#all(limit:, after_id:)` reads one page of memories in id order, and
|
|
17
|
+
`Memory#each_memory(batch_size:)` iterates a scope in batches. `all` without arguments is
|
|
18
|
+
unchanged.
|
|
19
|
+
- `Memory#forget_all(batch_size:, dry_run:)` and the `engram:forget_all[scope]` task delete
|
|
20
|
+
every memory in a scope in batches and return matched/deleted counts. Emits
|
|
21
|
+
`forget_all.engram`. The README describes the full erasure steps and an export example.
|
|
22
|
+
- Optional `MemoryStore#delete_many(scope:, ids:)`, implemented by the built-in stores; custom
|
|
23
|
+
stores without it fall back to `delete`.
|
|
24
|
+
- Optional `MemoryStore#find(scope:, id:)`. The in-memory and pgvector stores implement it;
|
|
25
|
+
custom stores without it fall back to scanning `all`.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- `InMemoryStore#update` keeps the stored `created_at` and `last_accessed_at`, matching the
|
|
30
|
+
pgvector store.
|
|
31
|
+
- `Memory#add` no longer sends content to the embedder before `before_persist` and the
|
|
32
|
+
persistence policy run. Rejected text is not embedded, and redacted text is embedded once,
|
|
33
|
+
after redaction.
|
|
34
|
+
- `has_memory` raises `Engram::Error` for an owner that is not persisted (new or destroyed)
|
|
35
|
+
instead of deriving its scope from an unsaved or reused id.
|
|
36
|
+
- The install generator configures the embedder with the requested `--dimensions` so the
|
|
37
|
+
initializer matches the migration.
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
|
|
41
|
+
- On `Memory#add`, `before_persist` hooks now receive a record without an embedding. Hooks
|
|
42
|
+
that read or transform `record.embedding` on this path must handle `nil`.
|
|
43
|
+
|
|
44
|
+
## [0.8.0] - 2026-09-15
|
|
45
|
+
|
|
46
|
+
### Added
|
|
47
|
+
|
|
48
|
+
- Optional `expires_at` on memories. Expired records are excluded from search and injection while remaining available for inspection and deletion.
|
|
49
|
+
- `engram:expiry` generator for existing Rails apps, with a concurrent index for scoped expiry queries.
|
|
50
|
+
- Scoped `forget_expired` cleanup with batching, dry runs, and a Rails task. Deletion rechecks the deadline so concurrent expiry extensions are preserved.
|
|
51
|
+
|
|
8
52
|
## [0.7.0] - 2026-09-13
|
|
9
53
|
|
|
10
54
|
### Added
|
data/README.md
CHANGED
|
@@ -76,7 +76,7 @@ explicitly version-gated:
|
|
|
76
76
|
- Core types and facade: `Engram::Memory`, `Engram::Record`, `Engram::Decision`, `Engram::PersistencePolicy`, and `Engram.with_memory`.
|
|
77
77
|
- Store and adapter ports: `Engram::Ports::MemoryStore`, `Engram::Ports::Embedder`, `Engram::Ports::Completion`.
|
|
78
78
|
- Rails integration points: `has_memory`, `Memory#observe_later`, and generator outputs under `engram:` rake tasks.
|
|
79
|
-
- Lifecycle methods in `Engram::Memory`: `add`, `recall`, `inject_into`, `observe`, `observe_later`, `forget`, `forget_stale`, `rebuild_embeddings`, and `memories_from_source`.
|
|
79
|
+
- Lifecycle methods in `Engram::Memory`: `add`, `update`, `all`, `each_memory`, `recall`, `inject_into`, `observe`, `observe_later`, `forget`, `forget_all`, `forget_stale`, `forget_expired`, `rebuild_embeddings`, and `memories_from_source`.
|
|
80
80
|
- RubyLLM adapter contract points and evaluator entrypoints (`rake eval`, `rake eval:real`).
|
|
81
81
|
|
|
82
82
|
### Backward-compatibility commitments (pre-1.0)
|
|
@@ -140,6 +140,9 @@ current_user.memory.add("Works at Acme Corp", kind: :fact)
|
|
|
140
140
|
current_user.memory.recall("where does the user work?")
|
|
141
141
|
```
|
|
142
142
|
|
|
143
|
+
The default scope needs a persisted record; calling `memory` on a new or destroyed owner
|
|
144
|
+
raises `Engram::Error`. Pass `has_memory scope: -> { ... }` to use a different key.
|
|
145
|
+
|
|
143
146
|
Run automatic observation off the request path:
|
|
144
147
|
|
|
145
148
|
```ruby
|
|
@@ -228,7 +231,7 @@ end
|
|
|
228
231
|
### Filtered vector search
|
|
229
232
|
|
|
230
233
|
Keep the generated B-tree index on `scope`. With an approximate vector index, pgvector
|
|
231
|
-
applies scope and
|
|
234
|
+
applies scope, kind, and expiry filters after the index scan. This can return fewer than `limit`
|
|
232
235
|
memories even when enough matching rows exist.
|
|
233
236
|
|
|
234
237
|
On pgvector 0.8.0+, HNSW iterative scans can search further:
|
|
@@ -388,6 +391,11 @@ Engram.configure do |config|
|
|
|
388
391
|
end
|
|
389
392
|
```
|
|
390
393
|
|
|
394
|
+
`Memory#add` runs `before_persist` and the policy before computing the embedding, so rejected
|
|
395
|
+
or redacted text is never sent to the embedder, and hooks receive a record without an
|
|
396
|
+
embedding. `observe` is different: the conversation goes to the completion provider, and
|
|
397
|
+
extracted facts are embedded for consolidation before hooks and policy run.
|
|
398
|
+
|
|
391
399
|
Write-content filtering and transformation are not deletion authorization. A consolidation
|
|
392
400
|
`forget` decision still validates scope, target existence, candidate integrity, and provenance, but it does not run
|
|
393
401
|
`before_persist` or the policy's `call` method; this allows secret, transient, and redacted
|
|
@@ -396,6 +404,47 @@ implementing `allow_destructive?(record)` and returning exactly `true` or `false
|
|
|
396
404
|
that only implement `call` affect writes but do not prevent deletion. The built-in policy uses
|
|
397
405
|
this separate hook to retain its ungrounded-provenance protection.
|
|
398
406
|
|
|
407
|
+
### Listing memories
|
|
408
|
+
|
|
409
|
+
`memory.all` returns every memory in the scope, including expired ones. For large scopes, read
|
|
410
|
+
one page at a time or iterate in batches:
|
|
411
|
+
|
|
412
|
+
```ruby
|
|
413
|
+
page = memory.all(limit: 50)
|
|
414
|
+
next_page = memory.all(limit: 50, after_id: page.last.id)
|
|
415
|
+
|
|
416
|
+
memory.each_memory(batch_size: 500) { |record| export(record) }
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
Both return memories in id order. A custom store whose `all` does not accept `limit:` and
|
|
420
|
+
`after_id:` is read in a single call.
|
|
421
|
+
|
|
422
|
+
### Correcting memories
|
|
423
|
+
|
|
424
|
+
```ruby
|
|
425
|
+
memory.update(id: record.id, content: "Lives in Berlin")
|
|
426
|
+
memory.update(id: record.id, importance: 0.2, expires_at: nil)
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
`update` changes only the attributes you pass (`content`, `kind`, `importance`, `metadata`,
|
|
430
|
+
`expires_at`) and keeps the id, `created_at`, and `last_accessed_at`. `metadata:` replaces the
|
|
431
|
+
application metadata; `{}` clears it, and the reserved `_engram` key is not accepted.
|
|
432
|
+
`expires_at: nil` removes the expiry. Other attributes do not accept `nil`.
|
|
433
|
+
|
|
434
|
+
The edit goes through `before_persist` and the persistence policy like a new memory, and a
|
|
435
|
+
policy that implements `allow_destructive?` must also allow replacing the original record.
|
|
436
|
+
Expired memories can be updated too. `update` returns the stored record, `nil` if the hook or policy rejects it (the stored memory is
|
|
437
|
+
left unchanged), and raises `Engram::MemoryNotFoundError` if the id is not in this scope.
|
|
438
|
+
The embedding is recomputed when the content changes, or generated if the stored memory has
|
|
439
|
+
no embedding.
|
|
440
|
+
|
|
441
|
+
Changing the content removes the record's provenance, because the old sources no longer
|
|
442
|
+
support the new text. Other edits keep it.
|
|
443
|
+
|
|
444
|
+
`update` reads the record, applies the changes, and writes it back without conflict
|
|
445
|
+
detection. If `observe`, an embedding rebuild, or another `update` writes the same memory at
|
|
446
|
+
the same time, one of the changes can be lost.
|
|
447
|
+
|
|
399
448
|
### Extraction provenance
|
|
400
449
|
|
|
401
450
|
Custom extractors remain compatible when they return an array of plain `Engram::Record`
|
|
@@ -553,6 +602,108 @@ Both settings default to nil. Pass nil explicitly to override a configured value
|
|
|
553
602
|
the cap. `limit: 0` skips embedding and search; `max_bytes: 0` skips recall during injection.
|
|
554
603
|
Invalid values raise `ArgumentError`.
|
|
555
604
|
|
|
605
|
+
### Memory expiry
|
|
606
|
+
|
|
607
|
+
Set a deadline for temporary memories:
|
|
608
|
+
|
|
609
|
+
```ruby
|
|
610
|
+
user.memory.add("Trial access is active", expires_at: 7.days.from_now)
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
`expires_at` accepts a Time (including Rails time-zone values) or nil. Nil is the default
|
|
614
|
+
and never expires. A memory is expired at `expires_at <= Time.now`. Both built-in stores
|
|
615
|
+
exclude expired records before the search limit, so they no longer participate in recall
|
|
616
|
+
or consolidation. Injection checks expiry again when rendering.
|
|
617
|
+
|
|
618
|
+
Expiry does not delete rows. `memory.all` still returns them, and `memory.forget(id:)` can
|
|
619
|
+
delete them. A new observation may recreate the same fact from retained source data.
|
|
620
|
+
Custom extractors or `before_persist` can set expiry with `record.with(expires_at: deadline)`;
|
|
621
|
+
Engram does not infer deadlines from conversation text. Store updates replace expiry with
|
|
622
|
+
the supplied record's value, including nil.
|
|
623
|
+
|
|
624
|
+
New Rails installs include the expiry column and index. For a table created by 0.7.0 or
|
|
625
|
+
earlier, generate and run the upgrade migration:
|
|
626
|
+
|
|
627
|
+
```sh
|
|
628
|
+
bin/rails generate engram:expiry
|
|
629
|
+
bin/rails db:migrate
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
The migration adds a nullable datetime column and builds a partial index on scope and
|
|
633
|
+
expiry concurrently. Existing rows keep their data and do not expire. Reads and
|
|
634
|
+
non-expiring writes continue to work before migration; expiring writes raise an error
|
|
635
|
+
instead of losing the deadline. Restart app processes after migrating so ActiveRecord
|
|
636
|
+
refreshes its cached schema. If you use a custom table, adjust the generated migration.
|
|
637
|
+
|
|
638
|
+
Custom stores must persist `expires_at` and exclude expired records before limiting search
|
|
639
|
+
results. Recall also discards any expired records a custom store returns, but cannot fill
|
|
640
|
+
the gaps left by a store that filters after its limit.
|
|
641
|
+
|
|
642
|
+
To remove expired rows, preview the count, then run cleanup:
|
|
643
|
+
|
|
644
|
+
```ruby
|
|
645
|
+
user.memory.forget_expired(dry_run: true)
|
|
646
|
+
# => {matched: 12, deleted: 0, dry_run: true}
|
|
647
|
+
user.memory.forget_expired(batch_size: 500)
|
|
648
|
+
# => {matched: 12, deleted: 12, dry_run: false}
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
The Rails task requires a scope:
|
|
652
|
+
|
|
653
|
+
```sh
|
|
654
|
+
DRY_RUN=true BATCH_SIZE=500 bin/rails 'engram:forget_expired[user:42]'
|
|
655
|
+
BATCH_SIZE=500 bin/rails 'engram:forget_expired[user:42]'
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
Cleanup uses one cutoff for the run and reads only IDs in batches. It rechecks scope and
|
|
659
|
+
expiry when deleting, so a concurrent extension or removal of the deadline is preserved.
|
|
660
|
+
`matched` can exceed `deleted` if records change during the run; dry-run counts are advisory.
|
|
661
|
+
Like `forget(id:)`, this is explicit deletion and bypasses persistence hooks and policies.
|
|
662
|
+
Errors stop the run; completed batches stay deleted and a retry can process the remainder.
|
|
663
|
+
Custom stores need the optional `expired_ids` and `delete_expired` methods for cleanup.
|
|
664
|
+
The `forget_expired.engram` event reports counts without memory content or IDs.
|
|
665
|
+
|
|
666
|
+
### Erasing or exporting a user's memory
|
|
667
|
+
|
|
668
|
+
To delete everything Engram stores for one owner, preview the count, then run it:
|
|
669
|
+
|
|
670
|
+
```ruby
|
|
671
|
+
user.memory.forget_all(dry_run: true)
|
|
672
|
+
# => {matched: 87, deleted: 0, dry_run: true}
|
|
673
|
+
user.memory.forget_all(batch_size: 500)
|
|
674
|
+
# => {matched: 87, deleted: 87, dry_run: false}
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
```sh
|
|
678
|
+
DRY_RUN=true bin/rails 'engram:forget_all[user:42]'
|
|
679
|
+
bin/rails 'engram:forget_all[user:42]'
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
`forget_all` deletes expired memories too and, like `forget(id:)`, bypasses hooks and policies.
|
|
683
|
+
It only removes rows in the memory store. Before running it for an erasure request:
|
|
684
|
+
|
|
685
|
+
1. Stop new `observe` and `observe_later` calls for the owner, and let queued `ObserveJob`s
|
|
686
|
+
finish or remove them. Otherwise they can write new memories during or after the erasure.
|
|
687
|
+
2. Run `forget_all`. Memories added while it runs may or may not be deleted, so check
|
|
688
|
+
`memory.all` afterwards.
|
|
689
|
+
3. Delete conversation transcripts and other source data your app keeps; Engram does not
|
|
690
|
+
store them. Processed-turn markers in the cache expire on their own and hold no content.
|
|
691
|
+
|
|
692
|
+
To export, iterate in batches and write the fields you need:
|
|
693
|
+
|
|
694
|
+
```ruby
|
|
695
|
+
File.open("memories.jsonl", "w") do |file|
|
|
696
|
+
user.memory.each_memory(batch_size: 500) do |record|
|
|
697
|
+
file.puts({id: record.id, kind: record.kind, content: record.content,
|
|
698
|
+
created_at: record.created_at, expires_at: record.expires_at}.to_json)
|
|
699
|
+
end
|
|
700
|
+
end
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
Custom stores can implement the optional `delete_many(scope:, ids:)` to delete a batch in one
|
|
704
|
+
call; without it, `forget_all` deletes one id at a time. The `forget_all.engram` event reports
|
|
705
|
+
counts without memory content or IDs.
|
|
706
|
+
|
|
556
707
|
### Observation and maintenance
|
|
557
708
|
|
|
558
709
|
Observation uses a scope-and-turn claim before extraction. While a claim lease is live and
|
|
@@ -639,7 +790,10 @@ When ActiveSupport is loaded, Engram emits `ActiveSupport::Notifications` events
|
|
|
639
790
|
main memory pipeline:
|
|
640
791
|
|
|
641
792
|
- `add.engram`
|
|
793
|
+
- `update.engram`
|
|
642
794
|
- `forget.engram`
|
|
795
|
+
- `forget_expired.engram`
|
|
796
|
+
- `forget_all.engram`
|
|
643
797
|
- `recall.engram`
|
|
644
798
|
- `inject.engram`
|
|
645
799
|
- `observe.engram`
|
|
@@ -785,6 +939,8 @@ gem unpack engram-*.gem --target /tmp/engram-package-check
|
|
|
785
939
|
- v0.5 (done): embedding provenance and scoped embedding rebuild operations.
|
|
786
940
|
- v0.6 (done): structured provenance, source impact lookup, and grounding reports.
|
|
787
941
|
- v0.7 (done): recall thresholds, injection limits, scoped deletion, and RubyLLM streaming fixes.
|
|
942
|
+
- v0.8 (done): explicit memory expiry, Rails upgrade migration, and scoped cleanup with dry runs.
|
|
943
|
+
- v0.9 (done): memory correction, paged listing and batched iteration, and scope erasure.
|
|
788
944
|
- later: additional storage backends and larger real-provider eval benchmarks.
|
|
789
945
|
|
|
790
946
|
## 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,82 @@ 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 } }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def find(scope:, id:)
|
|
55
|
+
@mutex.synchronize do
|
|
56
|
+
record = @records[id]
|
|
57
|
+
record if record&.scope == scope
|
|
58
|
+
end
|
|
49
59
|
end
|
|
50
60
|
|
|
51
61
|
def update(scope:, id:, record:)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
62
|
+
@mutex.synchronize do
|
|
63
|
+
existing = @records[id]
|
|
64
|
+
raise Engram::Error, "no memory with id #{id.inspect} in scope #{scope.inspect}" unless existing&.scope == scope
|
|
65
|
+
raise Engram::Error, "cannot move memory across scopes" unless record.scope == scope
|
|
55
66
|
|
|
56
|
-
|
|
57
|
-
|
|
67
|
+
@records[id] = record.with(id: id, created_at: existing.created_at, last_accessed_at: existing.last_accessed_at)
|
|
68
|
+
end
|
|
58
69
|
end
|
|
59
70
|
|
|
60
71
|
def delete(scope:, id:)
|
|
61
|
-
|
|
72
|
+
@mutex.synchronize do
|
|
73
|
+
return 0 unless @records[id]&.scope == scope
|
|
62
74
|
|
|
63
|
-
|
|
64
|
-
|
|
75
|
+
@records.delete(id)
|
|
76
|
+
1
|
|
77
|
+
end
|
|
65
78
|
end
|
|
66
79
|
|
|
67
80
|
def touch(scope:, id:, at: Time.now)
|
|
68
|
-
|
|
69
|
-
|
|
81
|
+
@mutex.synchronize do
|
|
82
|
+
record = @records[id]
|
|
83
|
+
return 0 unless record&.scope == scope
|
|
84
|
+
|
|
85
|
+
record.last_accessed_at = at
|
|
86
|
+
1
|
|
87
|
+
end
|
|
88
|
+
end
|
|
70
89
|
|
|
71
|
-
|
|
72
|
-
|
|
90
|
+
def expired_ids(scope:, at:, limit:, after_id: nil)
|
|
91
|
+
# IDs follow insertion order; the hash retains it across updates/deletes.
|
|
92
|
+
@mutex.synchronize do
|
|
93
|
+
@records.each_value.lazy
|
|
94
|
+
.select { |record| record.scope == scope && (after_id.nil? || record.id > after_id) && record.expired?(at: at) }
|
|
95
|
+
.map(&:id).take(limit).force
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def delete_many(scope:, ids:)
|
|
100
|
+
@mutex.synchronize do
|
|
101
|
+
ids.uniq.count do |id|
|
|
102
|
+
next false unless @records[id]&.scope == scope
|
|
103
|
+
|
|
104
|
+
@records.delete(id)
|
|
105
|
+
true
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def delete_expired(scope:, ids:, at:)
|
|
111
|
+
@mutex.synchronize do
|
|
112
|
+
ids.uniq.count do |id|
|
|
113
|
+
record = @records[id]
|
|
114
|
+
next false unless record&.scope == scope && record.expired?(at: at)
|
|
115
|
+
|
|
116
|
+
@records.delete(id)
|
|
117
|
+
true
|
|
118
|
+
end
|
|
119
|
+
end
|
|
73
120
|
end
|
|
74
121
|
|
|
75
122
|
def clear
|
|
76
|
-
@
|
|
77
|
-
|
|
123
|
+
@mutex.synchronize do
|
|
124
|
+
@records.clear
|
|
125
|
+
@sequence = 0
|
|
126
|
+
end
|
|
78
127
|
end
|
|
79
128
|
|
|
80
129
|
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
|
|
|
@@ -64,12 +69,18 @@ module Engram
|
|
|
64
69
|
end
|
|
65
70
|
end
|
|
66
71
|
|
|
72
|
+
def find(scope:, id:)
|
|
73
|
+
row = model.find_by(scope: scope, id: id)
|
|
74
|
+
to_record(row) if row
|
|
75
|
+
end
|
|
76
|
+
|
|
67
77
|
def update(scope:, id:, record:)
|
|
68
78
|
raise Engram::Error, "cannot move memory across scopes" unless record.scope == scope
|
|
69
79
|
|
|
70
80
|
model.transaction do
|
|
71
81
|
row = model.lock.find_by!(id: id, scope: scope)
|
|
72
82
|
row.update!(
|
|
83
|
+
**expiry_attributes(record),
|
|
73
84
|
content: record.content,
|
|
74
85
|
kind: record.kind.to_s,
|
|
75
86
|
importance: record.importance,
|
|
@@ -90,8 +101,43 @@ module Engram
|
|
|
90
101
|
model.where(id: id, scope: scope).update_all(last_accessed_at: at)
|
|
91
102
|
end
|
|
92
103
|
|
|
104
|
+
def expired_ids(scope:, at:, limit:, after_id: nil)
|
|
105
|
+
return [] unless expiry_column?
|
|
106
|
+
|
|
107
|
+
query = expired_scope(scope, at)
|
|
108
|
+
query = query.where(model.arel_table[:id].gt(after_id)) unless after_id.nil?
|
|
109
|
+
query.order(:id).limit(limit).pluck(:id)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def delete_many(scope:, ids:)
|
|
113
|
+
return 0 if ids.empty?
|
|
114
|
+
|
|
115
|
+
model.where(scope: scope, id: ids).delete_all
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def delete_expired(scope:, ids:, at:)
|
|
119
|
+
return 0 if ids.empty? || !expiry_column?
|
|
120
|
+
|
|
121
|
+
expired_scope(scope, at).where(id: ids).delete_all
|
|
122
|
+
end
|
|
123
|
+
|
|
93
124
|
private
|
|
94
125
|
|
|
126
|
+
def expired_scope(scope, at)
|
|
127
|
+
model.where(scope: scope).where(model.arel_table[:expires_at].lteq(at))
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def expiry_column?
|
|
131
|
+
model.column_names.include?("expires_at")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def expiry_attributes(record)
|
|
135
|
+
return {expires_at: record.expires_at} if expiry_column?
|
|
136
|
+
return {} if record.expires_at.nil?
|
|
137
|
+
|
|
138
|
+
raise Engram::Error, "PgvectorStore needs a nullable expires_at datetime column to store expiring memories"
|
|
139
|
+
end
|
|
140
|
+
|
|
95
141
|
def validate_scope!(scope)
|
|
96
142
|
raise Engram::Error, "memory scope cannot be nil" if scope.nil?
|
|
97
143
|
end
|
|
@@ -124,7 +170,8 @@ module Engram
|
|
|
124
170
|
importance: row.importance || 1.0,
|
|
125
171
|
metadata: row.metadata || {},
|
|
126
172
|
created_at: row.created_at,
|
|
127
|
-
last_accessed_at: row.try(:last_accessed_at)
|
|
173
|
+
last_accessed_at: row.try(:last_accessed_at),
|
|
174
|
+
expires_at: row.try(:expires_at)
|
|
128
175
|
)
|
|
129
176
|
end
|
|
130
177
|
|
|
@@ -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
|
@@ -4,6 +4,9 @@ module Engram
|
|
|
4
4
|
# The friendly facade. Bound to one `scope` (an owner), it wires the configured store
|
|
5
5
|
# and embedder into the use cases. This is what `user.memory` returns in Rails.
|
|
6
6
|
class Memory
|
|
7
|
+
UNSET = Object.new.freeze
|
|
8
|
+
private_constant :UNSET
|
|
9
|
+
|
|
7
10
|
attr_reader :scope
|
|
8
11
|
|
|
9
12
|
def initialize(scope:, store: Engram.config.store, embedder: Engram.config.embedder)
|
|
@@ -14,21 +17,26 @@ module Engram
|
|
|
14
17
|
|
|
15
18
|
# Persist a memory record of the given kind. Returns nil when the configured
|
|
16
19
|
# persistence policy rejects the record.
|
|
17
|
-
def add(content, kind: :fact, importance: 1.0, metadata: {})
|
|
20
|
+
def add(content, kind: :fact, importance: 1.0, metadata: {}, expires_at: nil)
|
|
18
21
|
Engram::Instrumentation.instrument("add", Engram::Instrumentation.payload(scope: scope, store: @store, kind: kind)) do
|
|
19
|
-
|
|
20
|
-
record = Record.new(
|
|
22
|
+
persist(Record.new(
|
|
21
23
|
content: content,
|
|
22
24
|
scope: scope,
|
|
23
|
-
embedding: embedding,
|
|
24
25
|
kind: kind,
|
|
25
26
|
importance: importance,
|
|
26
|
-
metadata: metadata
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
metadata: metadata,
|
|
28
|
+
expires_at: expires_at
|
|
29
|
+
))
|
|
29
30
|
end
|
|
30
31
|
end
|
|
31
32
|
|
|
33
|
+
# Correct supplied attributes of a memory in this scope. Omitted fields are kept.
|
|
34
|
+
def update(id:, content: UNSET, kind: UNSET, importance: UNSET, metadata: UNSET, expires_at: UNSET)
|
|
35
|
+
attributes = {content: content, kind: kind, importance: importance, metadata: metadata, expires_at: expires_at}
|
|
36
|
+
.reject { |_, value| value.equal?(UNSET) }
|
|
37
|
+
UseCases::Update.new(store: @store, embedder: @embedder).call(scope: scope, id: id, attributes: attributes)
|
|
38
|
+
end
|
|
39
|
+
|
|
32
40
|
# Return the most relevant memories for a query.
|
|
33
41
|
def recall(query, limit: Engram.config.default_limit, kinds: nil,
|
|
34
42
|
min_similarity: Engram.config.recall_min_similarity)
|
|
@@ -85,15 +93,35 @@ module Engram
|
|
|
85
93
|
end
|
|
86
94
|
end
|
|
87
95
|
|
|
88
|
-
|
|
89
|
-
|
|
96
|
+
# Memories in this scope, including expired ones. Pass limit and after_id (the last id of
|
|
97
|
+
# the previous page) to read one page in id order.
|
|
98
|
+
def all(limit: nil, after_id: nil)
|
|
99
|
+
return @store.all(scope: scope) if limit.nil? && after_id.nil?
|
|
100
|
+
if !limit.nil? && !(limit.is_a?(Integer) && limit.positive?)
|
|
101
|
+
raise ArgumentError, "limit must be a positive integer"
|
|
102
|
+
end
|
|
103
|
+
if !after_id.nil? && !valid_id?(after_id)
|
|
104
|
+
raise ArgumentError, "after_id must be an Integer or a non-empty String"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
@store.all(scope: scope, limit: limit, after_id: after_id)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Yield every memory in this scope in id order, loading batch_size records at a time.
|
|
111
|
+
# Returns an Enumerator without a block.
|
|
112
|
+
def each_memory(batch_size: 100, &block)
|
|
113
|
+
unless batch_size.is_a?(Integer) && batch_size.positive?
|
|
114
|
+
raise ArgumentError, "batch_size must be a positive integer"
|
|
115
|
+
end
|
|
116
|
+
return enum_for(:each_memory, batch_size: batch_size) unless block
|
|
117
|
+
|
|
118
|
+
UseCases::EachMemory.new(store: @store).call(scope: scope, batch_size: batch_size, &block)
|
|
90
119
|
end
|
|
91
120
|
|
|
92
121
|
# Delete one memory in this scope. Returns 1 when deleted, or 0 when missing.
|
|
93
122
|
# Explicit deletion bypasses extraction, consolidation, and persistence hooks.
|
|
94
123
|
def forget(id:)
|
|
95
|
-
|
|
96
|
-
raise ArgumentError, "id must be an Integer or a non-empty String" unless valid_id
|
|
124
|
+
raise ArgumentError, "id must be an Integer or a non-empty String" unless valid_id?(id)
|
|
97
125
|
|
|
98
126
|
payload = Engram::Instrumentation.payload(scope: scope, store: @store)
|
|
99
127
|
Engram::Instrumentation.instrument("forget", payload) do
|
|
@@ -101,6 +129,12 @@ module Engram
|
|
|
101
129
|
end
|
|
102
130
|
end
|
|
103
131
|
|
|
132
|
+
# Delete every memory in this scope in batches. Returns matched and deleted counts.
|
|
133
|
+
# Bypasses extraction, consolidation, and persistence hooks, like #forget.
|
|
134
|
+
def forget_all(batch_size: 100, dry_run: false)
|
|
135
|
+
UseCases::ForgetAll.new(store: @store).call(scope: scope, batch_size: batch_size, dry_run: dry_run)
|
|
136
|
+
end
|
|
137
|
+
|
|
104
138
|
# Recompute embeddings (and embedding metadata) for memories in the scope.
|
|
105
139
|
# When `stale_only` is true, only records whose current metadata does not match the
|
|
106
140
|
# active embedder are rebuilt.
|
|
@@ -119,6 +153,13 @@ module Engram
|
|
|
119
153
|
.call(scope: scope, older_than: older_than, min_importance: min_importance)
|
|
120
154
|
end
|
|
121
155
|
|
|
156
|
+
# Delete expired records without loading their content or embeddings.
|
|
157
|
+
# Returns matched/deleted counts; dry runs only count eligible records.
|
|
158
|
+
def forget_expired(batch_size: 100, dry_run: false)
|
|
159
|
+
UseCases::ForgetExpired.new(store: @store)
|
|
160
|
+
.call(scope: scope, batch_size: batch_size, dry_run: dry_run)
|
|
161
|
+
end
|
|
162
|
+
|
|
122
163
|
# Return the memories in this scope whose provenance references the exact host
|
|
123
164
|
# source. `source_id` and `source_type` must each be non-blank Strings and are
|
|
124
165
|
# matched exactly, without normalization. Source IDs are references, not an
|
|
@@ -136,6 +177,10 @@ module Engram
|
|
|
136
177
|
|
|
137
178
|
private
|
|
138
179
|
|
|
180
|
+
def valid_id?(id)
|
|
181
|
+
id.instance_of?(Integer) || (id.instance_of?(String) && id.valid_encoding? && !id.strip.empty?)
|
|
182
|
+
end
|
|
183
|
+
|
|
139
184
|
def persist(record)
|
|
140
185
|
Persistence.new(store: @store, embedder: @embedder).add(record)
|
|
141
186
|
end
|
data/lib/engram/persistence.rb
CHANGED
|
@@ -48,20 +48,30 @@ module Engram
|
|
|
48
48
|
# Applies all write transformations without mutating the store. Input is validated
|
|
49
49
|
# before callbacks can remove or replace provenance, and final output is validated too.
|
|
50
50
|
def prepare(record)
|
|
51
|
-
original_provenance = validate_provenance!(record)
|
|
52
51
|
original_content = record.content
|
|
52
|
+
record = transform(record)
|
|
53
|
+
# Embed only after hooks and policy so rejected or redacted text never reaches the embedder.
|
|
54
|
+
if record && (record.embedding.nil? || record.content != original_content)
|
|
55
|
+
record = record.with(embedding: @embedder.embed(record.content))
|
|
56
|
+
end
|
|
57
|
+
record = Engram::EmbeddingMetadata.attach(record, embedder: @embedder) if record
|
|
58
|
+
validate_provenance!(record) if record
|
|
59
|
+
record
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Runs before_persist and the policy without embedding. Memory#update passes a block
|
|
63
|
+
# that runs after each step to check the result and drop invalidated provenance.
|
|
64
|
+
def transform(record)
|
|
65
|
+
original_provenance = validate_provenance!(record)
|
|
53
66
|
record = @before_persist.call(record) if @before_persist
|
|
54
67
|
if record
|
|
55
68
|
transformed_provenance = validate_provenance!(record)
|
|
56
69
|
validate_provenance_trust!(original_provenance, transformed_provenance) if @before_persist
|
|
57
70
|
end
|
|
71
|
+
record = yield(record) if record && block_given?
|
|
58
72
|
record = @persistence_policy.call(record) if record && @persistence_policy
|
|
59
73
|
validate_provenance!(record) if record
|
|
60
|
-
|
|
61
|
-
record = record.with(embedding: @embedder.embed(record.content))
|
|
62
|
-
end
|
|
63
|
-
record = Engram::EmbeddingMetadata.attach(record, embedder: @embedder) if record
|
|
64
|
-
validate_provenance!(record) if record
|
|
74
|
+
record = yield(record) if record && block_given?
|
|
65
75
|
record
|
|
66
76
|
end
|
|
67
77
|
|