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 +4 -4
- data/CHANGELOG.md +81 -0
- data/README.md +229 -9
- data/lib/engram/adapters/in_memory_store.rb +5 -1
- data/lib/engram/adapters/pgvector_store.rb +15 -0
- data/lib/engram/configuration.rb +4 -1
- data/lib/engram/extraction.rb +30 -0
- data/lib/engram/integrations/ruby_llm.rb +15 -7
- data/lib/engram/internal/candidate_integrity.rb +510 -0
- data/lib/engram/internal/core_hash.rb +36 -0
- data/lib/engram/internal/scope.rb +31 -0
- data/lib/engram/memory.rb +35 -5
- data/lib/engram/persistence.rb +79 -9
- data/lib/engram/persistence_policy.rb +11 -1
- data/lib/engram/ports/consolidator.rb +7 -2
- data/lib/engram/ports/extractor.rb +1 -1
- data/lib/engram/ports/memory_store.rb +11 -0
- data/lib/engram/provenance.rb +349 -18
- data/lib/engram/railtie.rb +1 -3
- data/lib/engram/record.rb +12 -5
- data/lib/engram/use_cases/grounding_report.rb +44 -0
- data/lib/engram/use_cases/inject.rb +32 -7
- data/lib/engram/use_cases/observe.rb +266 -13
- data/lib/engram/use_cases/recall.rb +40 -3
- data/lib/engram/use_cases/source_impact.rb +42 -0
- data/lib/engram/version.rb +1 -1
- data/lib/engram.rb +6 -0
- data/lib/generators/engram/templates/initializer.rb.tt +5 -0
- 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: f2d96222b1c43e68da7da579a6b9d1d39f85f5cbe2480be6cd39a19dcf36adc6
|
|
4
|
+
data.tar.gz: 53bc4261b347e498d20ea4171164b9478c55134067c33715641017b7572c78a7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
-
|
|
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 `
|
|
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
|
|
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
|
-
|
|
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
|
-
-
|
|
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.
|
|
551
|
-
|
|
552
|
-
|
|
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 (
|
|
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
|
|
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
|
|
data/lib/engram/configuration.rb
CHANGED
|
@@ -8,13 +8,16 @@ module Engram
|
|
|
8
8
|
attr_accessor :store, :embedder, :completion, :default_limit,
|
|
9
9
|
:consolidator, :extraction_min_confidence, :processed_turns,
|
|
10
10
|
:importance_weight, :recency_weight, :recency_halflife, :touch_on_recall,
|
|
11
|
-
:persistence_policy, :before_persist, :instrumentation_scope_identifier
|
|
11
|
+
:persistence_policy, :before_persist, :instrumentation_scope_identifier,
|
|
12
|
+
:recall_min_similarity, :injection_max_bytes
|
|
12
13
|
|
|
13
14
|
def initialize
|
|
14
15
|
@store = Adapters::InMemoryStore.new
|
|
15
16
|
@embedder = Adapters::NullEmbedder.new
|
|
16
17
|
@completion = nil # required for observe (extract/consolidate); nil until configured
|
|
17
18
|
@default_limit = 5
|
|
19
|
+
@recall_min_similarity = nil # opt-in cosine floor, before importance/recency ranking
|
|
20
|
+
@injection_max_bytes = nil # cap the complete appended memory block, not the host prompt
|
|
18
21
|
@consolidator = :heuristic # :heuristic (deterministic) or :llm (LLM-as-judge)
|
|
19
22
|
@extraction_min_confidence = 0.5
|
|
20
23
|
@processed_turns = Adapters::InMemoryProcessedTurns.new # idempotency for observe
|
|
@@ -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
|
-
|
|
22
|
+
def ask(message, **opts, &block)
|
|
23
|
+
augmented = @memory.inject_into(message.to_s, query: message.to_s, limit: @limit,
|
|
24
|
+
kinds: @kinds, min_similarity: @min_similarity, max_bytes: @max_bytes)
|
|
25
|
+
@chat.ask(augmented, **opts, &block)
|
|
21
26
|
end
|
|
22
27
|
|
|
23
28
|
def method_missing(name, *args, **kwargs, &block)
|
|
24
29
|
return super unless @chat.respond_to?(name)
|
|
25
30
|
|
|
26
|
-
@chat.public_send(name, *args, **kwargs, &block)
|
|
31
|
+
result = @chat.public_send(name, *args, **kwargs, &block)
|
|
32
|
+
result.equal?(@chat) ? self : result
|
|
27
33
|
end
|
|
28
34
|
|
|
29
35
|
def respond_to_missing?(name, include_private = false)
|
|
@@ -34,7 +40,9 @@ module Engram
|
|
|
34
40
|
end
|
|
35
41
|
|
|
36
42
|
# Convenience entrypoint.
|
|
37
|
-
def self.with_memory(chat, memory:, limit: config.default_limit
|
|
38
|
-
|
|
43
|
+
def self.with_memory(chat, memory:, limit: config.default_limit, kinds: nil,
|
|
44
|
+
min_similarity: config.recall_min_similarity, max_bytes: config.injection_max_bytes)
|
|
45
|
+
Integrations::RubyLLM::MemoryChat.new(chat, memory: memory, limit: limit, kinds: kinds,
|
|
46
|
+
min_similarity: min_similarity, max_bytes: max_bytes)
|
|
39
47
|
end
|
|
40
48
|
end
|