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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f2d96222b1c43e68da7da579a6b9d1d39f85f5cbe2480be6cd39a19dcf36adc6
4
- data.tar.gz: 53bc4261b347e498d20ea4171164b9478c55134067c33715641017b7572c78a7
3
+ metadata.gz: 50765b71eaace1c3abc16581e67c2466bf992fa023b082fe29d4b2c889cfb9d9
4
+ data.tar.gz: c439b5c3f34b9c91b8e1b7099b015c9b05eda5db93edc66de2bd913370c19303
5
5
  SHA512:
6
- metadata.gz: a8001a49d7c280ded764b75e2f62695f3eeaf229e114e5455ee9bfa32f58d0c2a94fd9d70a1b563e246da0efcb570fe5f95ca6ecd61e69ca3cad758a0491abae
7
- data.tar.gz: 29d1e339241f3edb54485925f20b78f81740770fc966c8e838cefaef839e191597080249f578f33c8f79c5eb2cc89563b0187cf897b296ab660ff4510a3b0d4d
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 kind filters after the index scan. This can return fewer than `limit`
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
- record.id = (@sequence += 1)
19
- @records[record.id] = record
20
- record
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
- .values
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
- existing = @records[id]
53
- raise Engram::Error, "no memory with id #{id.inspect} in scope #{scope.inspect}" unless existing&.scope == scope
54
- raise Engram::Error, "cannot move memory across scopes" unless record.scope == scope
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
- record.id = id
57
- @records[id] = record
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
- return 0 unless @records[id]&.scope == scope
72
+ @mutex.synchronize do
73
+ return 0 unless @records[id]&.scope == scope
62
74
 
63
- @records.delete(id)
64
- 1
75
+ @records.delete(id)
76
+ 1
77
+ end
65
78
  end
66
79
 
67
80
  def touch(scope:, id:, at: Time.now)
68
- record = @records[id]
69
- return 0 unless record&.scope == scope
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
- record.last_accessed_at = at
72
- 1
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
- @records.clear
77
- @sequence = 0
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
- embedding = @embedder.embed(content)
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
- persist(record)
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
- def all
89
- @store.all(scope: scope)
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
- valid_id = id.instance_of?(Integer) || (id.instance_of?(String) && id.valid_encoding? && !id.strip.empty?)
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
@@ -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
- if record && record.content != original_content
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