engram 0.3.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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +38 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +202 -0
  5. data/lib/engram/adapters/fake_completion.rb +32 -0
  6. data/lib/engram/adapters/in_memory_processed_turns.rb +29 -0
  7. data/lib/engram/adapters/in_memory_store.rb +58 -0
  8. data/lib/engram/adapters/null_embedder.rb +28 -0
  9. data/lib/engram/adapters/pgvector_store.rb +90 -0
  10. data/lib/engram/adapters/ruby_llm_completion.rb +43 -0
  11. data/lib/engram/adapters/ruby_llm_embedder.rb +35 -0
  12. data/lib/engram/configuration.rb +28 -0
  13. data/lib/engram/consolidators/heuristic_consolidator.rb +31 -0
  14. data/lib/engram/consolidators/llm_consolidator.rb +98 -0
  15. data/lib/engram/decision.rb +27 -0
  16. data/lib/engram/extractors/llm_extractor.rb +85 -0
  17. data/lib/engram/integrations/ruby_llm.rb +40 -0
  18. data/lib/engram/math.rb +23 -0
  19. data/lib/engram/memory.rb +105 -0
  20. data/lib/engram/ports/completion.rb +15 -0
  21. data/lib/engram/ports/consolidator.rb +17 -0
  22. data/lib/engram/ports/embedder.rb +19 -0
  23. data/lib/engram/ports/extractor.rb +15 -0
  24. data/lib/engram/ports/memory_store.rb +41 -0
  25. data/lib/engram/ports/processed_turns.rb +20 -0
  26. data/lib/engram/rails/cache_processed_turns.rb +31 -0
  27. data/lib/engram/rails/has_memory.rb +32 -0
  28. data/lib/engram/rails/observe_job.rb +11 -0
  29. data/lib/engram/railtie.rb +23 -0
  30. data/lib/engram/record.rb +35 -0
  31. data/lib/engram/turn_digest.rb +28 -0
  32. data/lib/engram/use_cases/forget.rb +28 -0
  33. data/lib/engram/use_cases/inject.rb +22 -0
  34. data/lib/engram/use_cases/observe.rb +59 -0
  35. data/lib/engram/use_cases/recall.rb +69 -0
  36. data/lib/engram/version.rb +5 -0
  37. data/lib/engram.rb +66 -0
  38. data/lib/generators/engram/install_generator.rb +48 -0
  39. data/lib/generators/engram/templates/create_engram_memories.rb.tt +24 -0
  40. data/lib/generators/engram/templates/initializer.rb.tt +12 -0
  41. data/lib/generators/engram/templates/memory_record.rb.tt +9 -0
  42. metadata +91 -0
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ module UseCases
5
+ # Prune stale memories from a scope. A memory is stale when its last activity
6
+ # (last_accessed_at, or created_at if never accessed) is older than the cutoff.
7
+ # `min_importance` keeps important memories even when they are old: only memories with
8
+ # importance below it are eligible. The default forgets all stale memories.
9
+ class Forget
10
+ def initialize(store:)
11
+ @store = store
12
+ end
13
+
14
+ # Returns the Array<Record> that were forgotten.
15
+ def call(scope:, older_than:, min_importance: Float::INFINITY, now: Time.now)
16
+ cutoff = now - older_than
17
+
18
+ stale = @store.all(scope: scope).select do |record|
19
+ timestamp = record.last_accessed_at || record.created_at
20
+ timestamp && timestamp < cutoff && record.importance.to_f < min_importance
21
+ end
22
+
23
+ stale.each { |record| @store.delete(id: record.id) if record.id }
24
+ stale
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ module UseCases
5
+ # Render recalled memories into a prompt as a clearly delimited block.
6
+ class Inject
7
+ DEFAULT_HEADER = "# What you remember about the user"
8
+
9
+ def initialize(header: DEFAULT_HEADER)
10
+ @header = header
11
+ end
12
+
13
+ # Returns a new prompt string. If there are no memories, the prompt is unchanged.
14
+ def call(prompt:, memories:)
15
+ return prompt if memories.nil? || memories.empty?
16
+
17
+ block = memories.map { |m| "- #{m.content}" }.join("\n")
18
+ "#{prompt}\n\n#{@header}:\n#{block}"
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ module UseCases
5
+ # Orchestrates a single observed turn: extract candidate facts, consolidate them
6
+ # against existing memory, and apply the resulting decisions to the store.
7
+ # Pure and synchronous — async execution is a Rails concern (see ObserveJob).
8
+ #
9
+ # When a ProcessedTurns store and an idempotency_key are provided, a turn that was
10
+ # already processed is skipped (no extraction, no duplicate memories).
11
+ class Observe
12
+ def initialize(store:, extractor:, consolidator:, processed_turns: nil)
13
+ @store = store
14
+ @extractor = extractor
15
+ @consolidator = consolidator
16
+ @processed_turns = processed_turns
17
+ end
18
+
19
+ # Returns the Array<Decision> that were applied (empty if skipped or nothing found).
20
+ def call(messages:, scope:, idempotency_key: nil)
21
+ return [] if already_processed?(idempotency_key)
22
+
23
+ candidates = @extractor.extract(messages: messages, scope: scope)
24
+ if candidates.empty?
25
+ mark_processed(idempotency_key)
26
+ return []
27
+ end
28
+
29
+ decisions = @consolidator.reconcile_all(candidates: candidates, scope: scope)
30
+ decisions.each { |decision| apply(decision) }
31
+ mark_processed(idempotency_key)
32
+ decisions
33
+ end
34
+
35
+ private
36
+
37
+ def already_processed?(key)
38
+ !!(key && @processed_turns&.seen?(key))
39
+ end
40
+
41
+ def mark_processed(key)
42
+ @processed_turns.record(key) if key && @processed_turns
43
+ end
44
+
45
+ def apply(decision)
46
+ case decision.action
47
+ when :add
48
+ @store.add(decision.candidate)
49
+ when :update
50
+ @store.update(id: decision.target_id, record: decision.candidate) if decision.target_id
51
+ when :forget
52
+ @store.delete(id: decision.target_id) if decision.target_id
53
+ when :noop
54
+ nil
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ module UseCases
5
+ # Embed a query and fetch the most relevant memories for a scope.
6
+ #
7
+ # By default this is pure vector similarity (the store's own ordering). When
8
+ # importance_weight or recency_weight are non-zero, it fetches a larger candidate pool
9
+ # and re-ranks by a composite score: similarity + importance + recency. With both
10
+ # weights at zero (the default) behaviour is identical to plain similarity search.
11
+ class Recall
12
+ DEFAULT_HALFLIFE = 30 * 24 * 60 * 60 # 30 days, in seconds
13
+ DEFAULT_POOL_FACTOR = 4
14
+
15
+ def initialize(store:, embedder:, importance_weight: 0.0, recency_weight: 0.0,
16
+ recency_halflife: DEFAULT_HALFLIFE, pool_factor: DEFAULT_POOL_FACTOR, touch: false)
17
+ @store = store
18
+ @embedder = embedder
19
+ @importance_weight = importance_weight.to_f
20
+ @recency_weight = recency_weight.to_f
21
+ @recency_halflife = recency_halflife.to_f
22
+ @pool_factor = pool_factor
23
+ @touch = touch
24
+ end
25
+
26
+ # Returns Array<Record>, most relevant first.
27
+ def call(query, scope:, limit: Engram.config.default_limit)
28
+ raise ArgumentError, "query must be a non-empty string" if query.to_s.strip.empty?
29
+
30
+ embedding = @embedder.embed(query)
31
+ pool_limit = reranking? ? limit * @pool_factor : limit
32
+ pool = @store.search(embedding: embedding, scope: scope, limit: pool_limit)
33
+
34
+ results = (reranking? ? rerank(pool, embedding) : pool).first(limit)
35
+ touch(results) if @touch
36
+ results
37
+ end
38
+
39
+ private
40
+
41
+ def reranking?
42
+ !@importance_weight.zero? || !@recency_weight.zero?
43
+ end
44
+
45
+ def rerank(records, query_embedding)
46
+ now = Time.now
47
+ records.sort_by do |record|
48
+ similarity = Engram::Math.cosine_similarity(query_embedding, record.embedding)
49
+ score = similarity +
50
+ (@importance_weight * record.importance.to_f) +
51
+ (@recency_weight * recency(record, now))
52
+ -score
53
+ end
54
+ end
55
+
56
+ def recency(record, now)
57
+ timestamp = record.last_accessed_at || record.created_at
58
+ return 0.0 unless timestamp
59
+
60
+ age = now - timestamp
61
+ 0.5**(age / @recency_halflife)
62
+ end
63
+
64
+ def touch(records)
65
+ records.each { |record| @store.touch(id: record.id, at: Time.now) if record.id }
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ VERSION = "0.3.0"
5
+ end
data/lib/engram.rb ADDED
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "engram/version"
4
+ require_relative "engram/configuration"
5
+ require_relative "engram/math"
6
+ require_relative "engram/record"
7
+ require_relative "engram/decision"
8
+ require_relative "engram/turn_digest"
9
+
10
+ # Ports (contracts)
11
+ require_relative "engram/ports/memory_store"
12
+ require_relative "engram/ports/embedder"
13
+ require_relative "engram/ports/completion"
14
+ require_relative "engram/ports/extractor"
15
+ require_relative "engram/ports/consolidator"
16
+ require_relative "engram/ports/processed_turns"
17
+
18
+ # Use cases
19
+ require_relative "engram/use_cases/recall"
20
+ require_relative "engram/use_cases/inject"
21
+ require_relative "engram/use_cases/observe"
22
+ require_relative "engram/use_cases/forget"
23
+
24
+ # Built-in adapters (pure Ruby, no external deps)
25
+ require_relative "engram/adapters/in_memory_store"
26
+ require_relative "engram/adapters/null_embedder"
27
+ require_relative "engram/adapters/fake_completion"
28
+ require_relative "engram/adapters/in_memory_processed_turns"
29
+
30
+ # Optional adapters. These reference external libraries (neighbor, ruby_llm) only at
31
+ # call time, so requiring the files here is safe even if those gems are absent.
32
+ require_relative "engram/adapters/pgvector_store"
33
+ require_relative "engram/adapters/ruby_llm_embedder"
34
+ require_relative "engram/adapters/ruby_llm_completion"
35
+
36
+ # Pipeline stages (v0.2)
37
+ require_relative "engram/extractors/llm_extractor"
38
+ require_relative "engram/consolidators/heuristic_consolidator"
39
+ require_relative "engram/consolidators/llm_consolidator"
40
+
41
+ require_relative "engram/memory"
42
+
43
+ # Optional integrations (pure Ruby; reference external libs only at call time)
44
+ require_relative "engram/integrations/ruby_llm"
45
+
46
+ # Public entrypoint and configuration store.
47
+ module Engram
48
+ class Error < StandardError; end
49
+
50
+ class << self
51
+ def config
52
+ @config ||= Configuration.new
53
+ end
54
+
55
+ def configure
56
+ yield config
57
+ end
58
+
59
+ # Reset configuration (primarily for tests).
60
+ def reset!
61
+ @config = Configuration.new
62
+ end
63
+ end
64
+ end
65
+
66
+ require_relative "engram/railtie" if defined?(Rails::Railtie)
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module Engram
7
+ module Generators
8
+ # `bin/rails generate engram:install [--dimensions=1536]`
9
+ # Creates the migration, an initializer, and the AR model.
10
+ class InstallGenerator < ::Rails::Generators::Base
11
+ include ::Rails::Generators::Migration
12
+
13
+ source_root File.expand_path("templates", __dir__)
14
+
15
+ DEFAULT_DIMENSIONS = 1536
16
+
17
+ class_option :dimensions, type: :numeric, default: DEFAULT_DIMENSIONS,
18
+ desc: "Embedding dimensions (match your embedding model)"
19
+
20
+ def create_migration_file
21
+ migration_template "create_engram_memories.rb.tt",
22
+ "db/migrate/create_engram_memories.rb"
23
+ end
24
+
25
+ def create_initializer
26
+ template "initializer.rb.tt", "config/initializers/engram.rb"
27
+ end
28
+
29
+ def create_model
30
+ template "memory_record.rb.tt", "app/models/engram/memory_record.rb"
31
+ end
32
+
33
+ def self.next_migration_number(dir)
34
+ ::ActiveRecord::Generators::Base.next_migration_number(dir)
35
+ end
36
+
37
+ private
38
+
39
+ def dimensions
40
+ options[:dimensions]
41
+ end
42
+
43
+ def migration_version
44
+ "#{::ActiveRecord::VERSION::MAJOR}.#{::ActiveRecord::VERSION::MINOR}"
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateEngramMemories < ActiveRecord::Migration[<%= migration_version %>]
4
+ def change
5
+ enable_extension "vector" unless extension_enabled?("vector")
6
+
7
+ create_table :engram_memories do |t|
8
+ t.string :scope, null: false
9
+ t.text :content, null: false
10
+ t.string :kind, null: false, default: "semantic"
11
+ t.float :importance, null: false, default: 1.0
12
+ t.jsonb :metadata, null: false, default: {}
13
+ t.vector :embedding, limit: <%= dimensions %>
14
+ t.datetime :last_accessed_at
15
+
16
+ t.timestamps
17
+ end
18
+
19
+ add_index :engram_memories, :scope
20
+
21
+ # For larger datasets, add an approximate index (requires data present first):
22
+ # add_index :engram_memories, :embedding, using: :hnsw, opclass: :vector_cosine_ops
23
+ end
24
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ Engram.configure do |config|
4
+ # Persist memories in your own Postgres via pgvector + neighbor.
5
+ config.store = Engram::Adapters::PgvectorStore.new
6
+
7
+ # Generate embeddings via RubyLLM. Configure RubyLLM separately with your provider keys.
8
+ config.embedder = Engram::Adapters::RubyLLMEmbedder.new
9
+
10
+ # How many memories to recall by default.
11
+ config.default_limit = 5
12
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ class MemoryRecord < ApplicationRecord
5
+ self.table_name = "engram_memories"
6
+
7
+ has_neighbors :embedding
8
+ end
9
+ end
metadata ADDED
@@ -0,0 +1,91 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: engram
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - Alexandr Kholodniak
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-05-25 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: |
14
+ Engram gives AI agents durable, long-term memory. It recalls relevant facts about a
15
+ user and injects them into the prompt, so an agent appears to remember across sessions.
16
+ Framework-agnostic core with a ports-and-adapters design; first-class Rails and RubyLLM
17
+ integration. Your memories live in your own database — no external memory service.
18
+ email:
19
+ - alexandrkholodniak@gmail.com
20
+ executables: []
21
+ extensions: []
22
+ extra_rdoc_files: []
23
+ files:
24
+ - CHANGELOG.md
25
+ - LICENSE.txt
26
+ - README.md
27
+ - lib/engram.rb
28
+ - lib/engram/adapters/fake_completion.rb
29
+ - lib/engram/adapters/in_memory_processed_turns.rb
30
+ - lib/engram/adapters/in_memory_store.rb
31
+ - lib/engram/adapters/null_embedder.rb
32
+ - lib/engram/adapters/pgvector_store.rb
33
+ - lib/engram/adapters/ruby_llm_completion.rb
34
+ - lib/engram/adapters/ruby_llm_embedder.rb
35
+ - lib/engram/configuration.rb
36
+ - lib/engram/consolidators/heuristic_consolidator.rb
37
+ - lib/engram/consolidators/llm_consolidator.rb
38
+ - lib/engram/decision.rb
39
+ - lib/engram/extractors/llm_extractor.rb
40
+ - lib/engram/integrations/ruby_llm.rb
41
+ - lib/engram/math.rb
42
+ - lib/engram/memory.rb
43
+ - lib/engram/ports/completion.rb
44
+ - lib/engram/ports/consolidator.rb
45
+ - lib/engram/ports/embedder.rb
46
+ - lib/engram/ports/extractor.rb
47
+ - lib/engram/ports/memory_store.rb
48
+ - lib/engram/ports/processed_turns.rb
49
+ - lib/engram/rails/cache_processed_turns.rb
50
+ - lib/engram/rails/has_memory.rb
51
+ - lib/engram/rails/observe_job.rb
52
+ - lib/engram/railtie.rb
53
+ - lib/engram/record.rb
54
+ - lib/engram/turn_digest.rb
55
+ - lib/engram/use_cases/forget.rb
56
+ - lib/engram/use_cases/inject.rb
57
+ - lib/engram/use_cases/observe.rb
58
+ - lib/engram/use_cases/recall.rb
59
+ - lib/engram/version.rb
60
+ - lib/generators/engram/install_generator.rb
61
+ - lib/generators/engram/templates/create_engram_memories.rb.tt
62
+ - lib/generators/engram/templates/initializer.rb.tt
63
+ - lib/generators/engram/templates/memory_record.rb.tt
64
+ homepage: https://github.com/kholdrex/engram
65
+ licenses:
66
+ - MIT
67
+ metadata:
68
+ homepage_uri: https://github.com/kholdrex/engram
69
+ source_code_uri: https://github.com/kholdrex/engram
70
+ changelog_uri: https://github.com/kholdrex/engram/blob/main/CHANGELOG.md
71
+ rubygems_mfa_required: 'true'
72
+ post_install_message:
73
+ rdoc_options: []
74
+ require_paths:
75
+ - lib
76
+ required_ruby_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: 3.2.0
81
+ required_rubygems_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ requirements: []
87
+ rubygems_version: 3.5.9
88
+ signing_key:
89
+ specification_version: 4
90
+ summary: Long-term memory for AI agents in Ruby — stored in your own Postgres.
91
+ test_files: []