engram 0.5.0 → 0.7.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: 8a38dfd456e4e89f37a9d4d8949a5bb77b9736d95621e9172ba1db72fb3a5771
4
- data.tar.gz: 0b537ee13bea0313fd770c529024af45e9ace8fb84f7e49621c2dc7b9d6b888c
3
+ metadata.gz: f2d96222b1c43e68da7da579a6b9d1d39f85f5cbe2480be6cd39a19dcf36adc6
4
+ data.tar.gz: 53bc4261b347e498d20ea4171164b9478c55134067c33715641017b7572c78a7
5
5
  SHA512:
6
- metadata.gz: 768d5d18a9ce04f60db58957127df76fbdc30482df4976d7d04b082af005850ba821ba45a35417c33946668af60331e869cf930e6239409ba10168a453f58665
7
- data.tar.gz: 7ec7997ba40947deede93b6e970a549bf6caae32eed79f0e4c3922ebecbf05bc1545b5565afc521c9dab6c0f95b27898b3ae9bd9f3f57d9935a48d18e9211322
6
+ metadata.gz: a8001a49d7c280ded764b75e2f62695f3eeaf229e114e5455ee9bfa32f58d0c2a94fd9d70a1b563e246da0efcb570fe5f95ca6ecd61e69ca3cad758a0491abae
7
+ data.tar.gz: 29d1e339241f3edb54485925f20b78f81740770fc966c8e838cefaef839e191597080249f578f33c8f79c5eb2cc89563b0187cf897b296ab660ff4510a3b0d4d
data/CHANGELOG.md CHANGED
@@ -5,6 +5,87 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.7.0] - 2026-09-13
9
+
10
+ ### Added
11
+ - `Memory#forget(id:)` deletes one memory in the current scope, returning `1` when deleted
12
+ or `0` when missing. Explicit deletion bypasses persistence hooks and emits `forget.engram`
13
+ with the deleted count. Arrays, ranges, nil, and blank IDs are rejected.
14
+ - Optional `min_similarity:` on recall, injection, and `Engram.with_memory`, with
15
+ `config.recall_min_similarity` as the facade default. Filters cosine similarity before
16
+ importance/recency ranking and touching; nil preserves existing top-k behavior.
17
+ - Optional `max_bytes:` on injection and `Engram.with_memory`, with
18
+ `config.injection_max_bytes` as the default. Includes the complete appended memory block,
19
+ skips whole memories that do not fit, and preserves the original prompt.
20
+ - Metrics for filtered candidates, injected/skipped memories, and appended bytes.
21
+ - `MIN_SIMILARITY` eval control and negative-query false-positive/abstention measurements.
22
+
23
+ ### Fixed
24
+ - Load `ObserveJob` on first use so `observe_later` works before another job has loaded
25
+ `ActiveJob::Base` in a Rails application.
26
+ - Forward RubyLLM streaming blocks and retain the memory wrapper when fluent chat methods
27
+ return the underlying chat, so chained configuration does not bypass memory injection.
28
+
29
+ ### Changed
30
+ - Recall rejects negative, fractional, nil, and string limits with `ArgumentError`; use an
31
+ integer. A zero limit skips embedding and search. A zero injection byte budget skips recall.
32
+
33
+ ## [0.6.0] - 2026-07-24
34
+
35
+ ### Added
36
+ - `Engram::Memory#grounding_report` (and `Engram::UseCases::GroundingReport`) returns frozen,
37
+ scope-bound record counts by weakest source alignment, with legacy or unrecognized provenance
38
+ counted as unattributed and no record or source text exposed.
39
+ - Add an immutable host-supplied source-text contract that validates provenance source identity
40
+ and Unicode-codepoint span bounds, and resolves authorized supporting text without retaining
41
+ source transcripts.
42
+ - Optional `Engram::Extraction` results let custom extractors attach versioned source
43
+ provenance while remaining compatible with plain `Engram::Record` results.
44
+ - `Engram::Record#provenance` exposes understood supporting source IDs, alignments, and spans
45
+ on recalled records while preserving tolerant reads for legacy and future schemas.
46
+ - `Engram::Memory#memories_from_source` (and `Engram::UseCases::SourceImpact`) return the
47
+ records in a scope whose provenance references an exact host source. `source_id` and
48
+ `source_type` must each be a non-blank String and are matched exactly without trimming or
49
+ normalization. The lookup is scope-bound and returns only records, never source
50
+ text; source IDs are references, not an authorization boundary. Legacy, malformed, and
51
+ future-schema provenance do not match.
52
+
53
+ ### Changed
54
+ - Persistence accepts records without provenance, rejects structurally ungrounded provenance
55
+ by default (configurable only with the exact boolean `allow_ungrounded: true`), and fails closed on malformed or
56
+ unknown future provenance during writes while keeping reads tolerant. Provenance validation
57
+ does not verify source text, and source IDs are references rather than authorization
58
+ boundaries. The built-in LLM extractor does not emit grounded provenance.
59
+ - Existing `before_persist` hooks may continue to transform record content or embeddings, but
60
+ may neither add, remove, nor alter provenance; this restriction includes legacy records with
61
+ no provenance.
62
+ - Extractors must return an `Array`; each result may be a plain `Engram::Record` or an
63
+ `Engram::Extraction` carrying provenance.
64
+ - Custom consolidator decisions must reference the actual same-scope `Engram::Record` instance
65
+ supplied for reconciliation and must treat both the candidates array and records as read-only.
66
+ Engram now fails closed on collection replacement/reordering/iteration overrides and on
67
+ security-relevant nested value, identity/alias/topology, frozen-state, custom-behavior, or
68
+ hidden-state changes before destructive authorization. Arbitrary application metadata values
69
+ remain opaque and preserve their identity without invoking equality or serialization behavior;
70
+ provenance is independently parsed and integrity-protected.
71
+ Substituted, modified, missing, malformed, or cross-scope candidates fail closed.
72
+ `Observe` preflights the complete decision batch, including persistence policy and hook
73
+ transformations, before beginning store mutations.
74
+
75
+ ### Fixed
76
+ - Provenance canonicalization now iterates untrusted metadata through core `Hash#keys`/`#values`
77
+ by position instead of `Hash#each_pair`. On Ruby 3.4 a caller-supplied hash left in a
78
+ delete-then-insert collision state could make `Hash#each_pair` invoke a key's `#eql?` mid-iteration,
79
+ letting a hostile `String`-subclass key run application code inside the behavior-free canonicalizer.
80
+ The adversarial hostile-key suite is now deterministic across Ruby 3.2–3.4.
81
+ - Observation rejects extractor candidates with caller-supplied IDs, and `InMemoryStore#add`
82
+ always allocates a fresh ID like the pgvector adapter, preventing same- or cross-scope record
83
+ replacement through add semantics.
84
+ - `forget` now separates destructive provenance authorization from write-content filtering and
85
+ redaction, so secret or transient memories can be deleted. Custom policies may implement
86
+ `allow_destructive?` with a strict boolean return; write-only policies no longer authorize
87
+ deletion through their `call` result.
88
+
8
89
  ## [0.5.0] - 2026-07-17
9
90
 
10
91
  ### Added
data/README.md CHANGED
@@ -52,12 +52,14 @@ chat.ask("Why am I being rate limited?")
52
52
 
53
53
  ## Feature overview
54
54
 
55
- - Zero-dependency pure Ruby core with in-memory defaults for tests and local development.
55
+ - Pure Ruby core with zero runtime dependencies and in-memory defaults for tests and local development.
56
56
  - Rails `has_memory` macro, install generator, and background `observe_later` job.
57
57
  - Postgres + pgvector storage through an optional ActiveRecord/neighbor adapter.
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`, and `rebuild_embeddings`.
79
+ - Lifecycle methods in `Engram::Memory`: `add`, `recall`, `inject_into`, `observe`, `observe_later`, `forget`, `forget_stale`, `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)
@@ -103,7 +105,7 @@ Use `CHANGELOG.md` as the authoritative source for breaking/compatibility change
103
105
  gem "engram"
104
106
  ```
105
107
 
106
- The core has **zero runtime dependencies**. Optional adapters need host-app dependencies:
108
+ The core has zero runtime dependencies and does not require Rails, ActiveRecord, or a database. Optional adapters need host-app dependencies:
107
109
 
108
110
  - `Engram::Adapters::PgvectorStore` → ActiveRecord + `neighbor` + Postgres/pgvector
109
111
  - `Engram::Adapters::RubyLLMEmbedder` and `Engram::Adapters::RubyLLMCompletion` → `ruby_llm`
@@ -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 and kind 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
@@ -291,6 +331,25 @@ memory.observe([
291
331
  # extracts "User is on the Pro plan", and if a "Free plan" memory exists, updates it
292
332
  ```
293
333
 
334
+ ### Custom consolidator candidate contract
335
+
336
+ `reconcile_all(candidates:, scope:)` receives a plain `Array` of plain `Engram::Record`
337
+ instances. A custom consolidator may inspect candidates and return decisions that reference
338
+ those exact instances, but it must not replace, append, remove, or reorder collection entries;
339
+ override collection iteration; add collection or record state or behavior; or mutate the
340
+ records. Engram verifies record and collection integrity before it authorizes any add, update,
341
+ or deletion and fails closed on a violation.
342
+
343
+ Plain arrays and hashes in candidate state are detached without serializing their leaves.
344
+ Core scalar/container state is integrity-protected without dispatching overridden traversal or
345
+ equality. Arbitrary application metadata values (including value objects, procs, and IO) remain
346
+ opaque, retain their identity, and are never compared or serialized by the integrity check.
347
+ Their internal mutable state is consequently outside that check; provenance remains parsed and
348
+ validated independently before any authorization. Containers must be acyclic, hashes cannot
349
+ have default procs, and behavior-bearing core values remain unsupported.
350
+ Extracted candidates must also have a nil `id`: IDs are store-owned and are allocated only
351
+ when an add is persisted.
352
+
294
353
  ## Memory kinds and persistence policy
295
354
 
296
355
  Every memory has a normalized `kind`:
@@ -329,6 +388,107 @@ Engram.configure do |config|
329
388
  end
330
389
  ```
331
390
 
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
393
+ `before_persist` or the policy's `call` method; this allows secret, transient, and redacted
394
+ memories to be removed. A custom policy can additionally control destructive decisions by
395
+ implementing `allow_destructive?(record)` and returning exactly `true` or `false`. Policies
396
+ that only implement `call` affect writes but do not prevent deletion. The built-in policy uses
397
+ this separate hook to retain its ungrounded-provenance protection.
398
+
399
+ ### Extraction provenance
400
+
401
+ Custom extractors remain compatible when they return an array of plain `Engram::Record`
402
+ values. They may instead return `Engram::Extraction` values to attach optional structured
403
+ `Engram::Provenance` to a candidate; arrays may contain either type. Records with no
404
+ provenance remain accepted.
405
+
406
+ Recalled records expose understood provenance directly, without loading source content:
407
+
408
+ ```ruby
409
+ record = memory.recall("where did this preference come from?").first
410
+
411
+ record.provenance&.sources&.each do |source|
412
+ puts [source.source_id, source.alignment, source.spans.map(&:to_h)].inspect
413
+ end
414
+ ```
415
+
416
+ When the host application has authorized and loaded the referenced message, it can validate
417
+ the source identity and span bounds before presenting supporting text:
418
+
419
+ ```ruby
420
+ source = record.provenance.sources.first
421
+ source_text = Engram::Provenance::SourceText.new(
422
+ source_id: source.source_id,
423
+ source_type: source.source_type,
424
+ message_index: source.message_index,
425
+ role: source.role,
426
+ text: authorized_message_body
427
+ )
428
+
429
+ source.validate_source_text!(source_text)
430
+ source.supporting_text(source_text) # text for each zero-based, end-exclusive codepoint span
431
+ ```
432
+
433
+ Validation fails when any source identity field differs or a span exceeds that one message.
434
+ Offsets count Unicode codepoints, not bytes or user-perceived grapheme clusters.
435
+ `exact` and `normalized` remain extractor assertions: this API verifies identity and bounds but
436
+ does not claim that supporting text semantically entails the memory.
437
+
438
+ `Record#provenance` returns `nil` for legacy records and for malformed or future schemas so
439
+ reads remain compatible. Treat the returned source IDs and spans as untrusted references.
440
+ They do not prove source access or authorize retrieving source content; applications must
441
+ enforce the record's scope and their own source authorization before resolving them.
442
+
443
+ By default, the persistence policy rejects provenance containing a source marked
444
+ `ungrounded`. Applications that intentionally accept it can opt out:
445
+
446
+ ```ruby
447
+ Engram.configure do |config|
448
+ config.persistence_policy = Engram::PersistencePolicy.new(allow_ungrounded: true)
449
+ end
450
+ ```
451
+
452
+ Persistence validation is structural: Engram does not retain source text or verify that an
453
+ alignment is truthful. Source IDs are host-owned references, not authorization boundaries.
454
+ The built-in `Engram::Extractors::LLMExtractor` continues to return plain records and does not
455
+ emit grounded provenance. Reads tolerate malformed or future provenance metadata, but writes
456
+ fail closed when provenance is malformed or uses an unknown future schema version; upgrade an
457
+ older writer before rewriting such records.
458
+
459
+ ### Source impact lookup
460
+
461
+ To find which memories in a scope a host source produced, look them up by exact source
462
+ reference:
463
+
464
+ ```ruby
465
+ current_user.memory.memories_from_source(
466
+ source_id: "conversation:42",
467
+ source_type: "conversation"
468
+ )
469
+ ```
470
+
471
+ `source_id` and `source_type` must each be a non-blank String and are matched exactly, with no
472
+ trimming or normalization. The lookup is bound to the scope and returns an `Array` of
473
+ `Engram::Record` values (empty when nothing matches) — never source text. Result order follows
474
+ the store adapter's enumeration and is not otherwise guaranteed. Source IDs are references,
475
+ not an authorization boundary, so continue to enforce scope and your own source authorization.
476
+ Legacy, malformed, and future-schema provenance do not match.
477
+
478
+ ### Grounding report
479
+
480
+ To measure provenance grounding coverage without exposing record or source text, request the
481
+ scope-bound report:
482
+
483
+ ```ruby
484
+ current_user.memory.grounding_report
485
+ # => {exact: 12, normalized: 3, inferred: 2, ungrounded: 1, unattributed: 4, total: 22}
486
+ ```
487
+
488
+ Each record is counted once by its weakest source alignment (`exact`, `normalized`, `inferred`,
489
+ then `ungrounded`). Records with no understood provenance, including legacy, malformed, and
490
+ future-schema provenance, count as `unattributed`. The returned counts-only Hash is frozen.
491
+
332
492
  ## Prompt-injection and memory-injection safety
333
493
 
334
494
  Injected memories are rendered as typed XML-like elements with escaped content, which keeps
@@ -362,6 +522,39 @@ with the old `semantic` kind value.
362
522
 
363
523
  ## Tuning and maintenance
364
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
+ ### Observation and maintenance
557
+
365
558
  Observation uses a scope-and-turn claim before extraction. While a claim lease is live and
366
559
  the turn has not completed, calls for the same scope and turn raise
367
560
  `Engram::ObservationInProgressError` instead of reporting success without doing the work; a
@@ -395,7 +588,24 @@ Engram.configure do |config|
395
588
  end
396
589
  ```
397
590
 
398
- Prune memories you no longer need:
591
+ Delete a specific memory:
592
+
593
+ ```ruby
594
+ record = current_user.memory.add("Prefers tea")
595
+ current_user.memory.forget(id: record.id) # => 1
596
+ current_user.memory.forget(id: record.id) # => 0 (already deleted)
597
+ ```
598
+
599
+ `forget` deletes one record within the memory's scope and returns the affected row count.
600
+ Missing records and IDs belonging to another scope return `0`. Pass the ID returned by
601
+ the store; integers and non-blank strings are accepted. Arrays, ranges, and nil raise
602
+ `ArgumentError`.
603
+
604
+ This is an explicit application operation: authorize the request before calling it.
605
+ It does not run extraction, consolidation, or persistence hooks, including `allow_destructive?`.
606
+ Source messages and processed-turn markers are kept; a later observation can recreate the memory.
607
+
608
+ Prune stale memories:
399
609
 
400
610
  ```ruby
401
611
  # Forget memories untouched for 90 days, but keep anything important
@@ -429,6 +639,7 @@ When ActiveSupport is loaded, Engram emits `ActiveSupport::Notifications` events
429
639
  main memory pipeline:
430
640
 
431
641
  - `add.engram`
642
+ - `forget.engram`
432
643
  - `recall.engram`
433
644
  - `inject.engram`
434
645
  - `observe.engram`
@@ -460,6 +671,11 @@ ActiveSupport::Notifications.subscribe(/\.engram\z/) do |name, _started, _finish
460
671
  end
461
672
  ```
462
673
 
674
+ Recall events include `candidate_count`, `filtered_count`, `result_count`, and
675
+ `min_similarity` when set. Injection events include the input `memory_count`,
676
+ `injected_count`, `skipped_count`, `injected_bytes`, and `max_bytes` when set.
677
+ `forget.engram` reports `deleted_count` without the record ID or content.
678
+
463
679
  Avoid adding memory content or raw prompts to subscriber logs; recalled content is
464
680
  user-derived and should be treated as sensitive application data.
465
681
 
@@ -472,7 +688,8 @@ user-derived and should be treated as sensitive application data.
472
688
  - Configure ActiveJob for `observe_later`; keep automatic observation off the request path.
473
689
  - Configure `Engram::Rails::CacheProcessedTurns` or another persistent processed-turns adapter for retries.
474
690
  - Review persistence policy settings and add app-specific redaction/denylist patterns.
475
- - Set recall limits and `kinds:` filters appropriate for your prompt budget and threat model.
691
+ - Calibrate `recall_min_similarity` and set `injection_max_bytes`, recall limits, and `kinds:` filters.
692
+ - Check filtered pgvector recall coverage and latency on representative tenant sizes.
476
693
  - Run the deterministic test/eval suite plus pgvector integration tests before release.
477
694
 
478
695
  ## How it works
@@ -547,9 +764,10 @@ safe to run in CI as a smoke test.
547
764
  The harness reports recall@k over labelled relevant memories, a labelled precision
548
765
  proxy@k, near-distractor retrieval rate, contradiction-pair full recall, extraction
549
766
  structured-output parsing cases, consolidation decision cases, and a heuristic duplicate-add
550
- baseline. Negative queries are printed for inspection, but top-k recall currently has no
551
- similarity threshold, so the harness does not report a hallucination rate. Treat the default
552
- NullEmbedder recall numbers as a mechanics check, not as a semantic retrieval benchmark.
767
+ baseline. Set `MIN_SIMILARITY=0.5` on either eval task to test a threshold. With a semantic
768
+ embedder, it also reports how often unrelated queries retrieve memories and how often
769
+ recall returns nothing. Compare these against positive-query recall when tuning the
770
+ threshold. NullEmbedder results only check mechanics; they do not measure semantic quality.
553
771
 
554
772
  Before opening a release PR, also verify the gem package:
555
773
 
@@ -564,7 +782,9 @@ gem unpack engram-*.gem --target /tmp/engram-package-check
564
782
  - v0.2 (done): extract and consolidate (ADD / UPDATE / FORGET), background jobs.
565
783
  - v0.3 (done): idempotent observation, importance/recency recall, forgetting and decay.
566
784
  - v0.4 (done): memory kinds, persistence policy, typed recall filters, safer injection, and observability hooks.
567
- - v0.5 (in progress): embedding provenance and scoped embedding rebuild operations.
785
+ - v0.5 (done): embedding provenance and scoped embedding rebuild operations.
786
+ - v0.6 (done): structured provenance, source impact lookup, and grounding reports.
787
+ - v0.7 (done): recall thresholds, injection limits, scoped deletion, and RubyLLM streaming fixes.
568
788
  - later: additional storage backends and larger real-provider eval benchmarks.
569
789
 
570
790
  ## License
@@ -15,7 +15,7 @@ module Engram
15
15
  def add(record)
16
16
  validate_scope!(record.scope)
17
17
 
18
- record.id ||= (@sequence += 1)
18
+ record.id = (@sequence += 1)
19
19
  @records[record.id] = record
20
20
  record
21
21
  end
@@ -44,6 +44,10 @@ module Engram
44
44
  records
45
45
  end
46
46
 
47
+ def existing_ids(scope:, ids:)
48
+ ids.uniq.select { |id| @records[id]&.scope == scope }
49
+ end
50
+
47
51
  def update(scope:, id:, record:)
48
52
  existing = @records[id]
49
53
  raise Engram::Error, "no memory with id #{id.inspect} in scope #{scope.inspect}" unless existing&.scope == scope
@@ -49,6 +49,21 @@ module Engram
49
49
  query.map { |row| to_record(row) }
50
50
  end
51
51
 
52
+ def existing_ids(scope:, ids:)
53
+ persisted_ids = model.where(scope: scope, id: ids).pluck(:id)
54
+ id_type = model.type_for_attribute("id")
55
+ persisted_lookup = persisted_ids.each_with_object({}) { |id, lookup| lookup[id] = true }
56
+ matched_ids = {}
57
+
58
+ ids.select do |id|
59
+ cast_id = id_type.cast(id)
60
+ next false unless persisted_lookup[cast_id]
61
+ next false if matched_ids[cast_id]
62
+
63
+ matched_ids[cast_id] = true
64
+ end
65
+ end
66
+
52
67
  def update(scope:, id:, record:)
53
68
  raise Engram::Error, "cannot move memory across scopes" unless record.scope == scope
54
69
 
@@ -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
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ # An immutable extractor result that carries a candidate Record and its source provenance.
5
+ # Converting it creates a new record and leaves the caller-owned record and metadata untouched.
6
+ class Extraction
7
+ attr_reader :record, :provenance
8
+
9
+ def initialize(record:, provenance:)
10
+ is_record = Object.instance_method(:is_a?).bind_call(record, Engram::Record)
11
+ raise ArgumentError, "record must be an Engram::Record" unless is_record
12
+
13
+ is_provenance = Object.instance_method(:is_a?).bind_call(provenance, Engram::Provenance)
14
+ raise ArgumentError, "provenance must be an Engram::Provenance" unless is_provenance
15
+
16
+ @record = record
17
+ @provenance = provenance
18
+ freeze
19
+ end
20
+
21
+ def to_record
22
+ source = Engram::Internal::CandidateIntegrity.new.detach(record)
23
+ attributes = Engram::Record::STATE_READERS.to_h do |attribute|
24
+ [attribute, Engram::Record.instance_method(attribute).bind_call(source)]
25
+ end
26
+ attributes[:metadata] = Engram::Provenance.attach(attributes.fetch(:metadata), provenance)
27
+ Engram::Record.new(**attributes)
28
+ end
29
+ end
30
+ end
@@ -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
- @chat.ask(augmented, **opts)
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
- Integrations::RubyLLM::MemoryChat.new(chat, memory: memory, limit: limit)
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