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,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Engram
6
+ module Consolidators
7
+ # LLM-as-judge consolidation. For each candidate it gathers the nearest existing
8
+ # memories (vector pre-filter) and asks the model, in a single batched call, what to
9
+ # do: add / update / forget / noop.
10
+ class LLMConsolidator
11
+ include Ports::Consolidator
12
+
13
+ SYSTEM = <<~PROMPT
14
+ You maintain a user's long-term memory. For each candidate fact, decide how it
15
+ relates to the existing memories provided:
16
+ - "add": genuinely new information
17
+ - "update": supersedes a specific existing memory (e.g. a changed preference)
18
+ - "forget": an existing memory is now contradicted or obsolete
19
+ - "noop": already known, or not worth storing
20
+ Use "update"/"forget" only with the id of an existing memory shown for that candidate.
21
+ Return one decision per candidate, referencing it by its index.
22
+ PROMPT
23
+
24
+ SCHEMA = {
25
+ type: "object",
26
+ properties: {
27
+ decisions: {
28
+ type: "array",
29
+ items: {
30
+ type: "object",
31
+ properties: {
32
+ index: {type: "integer"},
33
+ action: {type: "string", enum: %w[add update forget noop]},
34
+ target_id: {type: %w[integer string null]},
35
+ reason: {type: "string"}
36
+ },
37
+ required: %w[index action]
38
+ }
39
+ }
40
+ },
41
+ required: %w[decisions]
42
+ }.freeze
43
+
44
+ def initialize(store:, completion:, neighbors: 5)
45
+ @store = store
46
+ @completion = completion
47
+ @neighbors = neighbors
48
+ end
49
+
50
+ def reconcile_all(candidates:, scope:)
51
+ candidates = Array(candidates)
52
+ return [] if candidates.empty?
53
+
54
+ result = @completion.complete(
55
+ system: SYSTEM,
56
+ user: JSON.generate(payload(candidates, scope)),
57
+ schema: SCHEMA
58
+ )
59
+ map_decisions(decisions(result), candidates)
60
+ end
61
+
62
+ private
63
+
64
+ def payload(candidates, scope)
65
+ items = candidates.each_with_index.map do |candidate, index|
66
+ existing = @store.search(embedding: candidate.embedding, scope: scope, limit: @neighbors)
67
+ {
68
+ index: index,
69
+ candidate: candidate.content,
70
+ existing: existing.map { |r| {id: r.id, content: r.content} }
71
+ }
72
+ end
73
+ {candidates: items}
74
+ end
75
+
76
+ def decisions(result)
77
+ return [] unless result.is_a?(Hash)
78
+
79
+ result["decisions"] || result[:decisions] || []
80
+ end
81
+
82
+ def map_decisions(raw, candidates)
83
+ raw.filter_map do |decision|
84
+ decision = decision.transform_keys(&:to_s)
85
+ index = decision["index"]
86
+ next unless index && candidates[index]
87
+
88
+ Engram::Decision.new(
89
+ action: (decision["action"] || "noop").to_sym,
90
+ candidate: candidates[index],
91
+ target_id: decision["target_id"],
92
+ reason: decision["reason"]
93
+ )
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ # The outcome of consolidating one candidate fact against existing memory.
5
+ #
6
+ # action - :add | :update | :forget | :noop
7
+ # candidate - the Record produced by extraction
8
+ # target_id - id of the existing memory to update/forget (nil for add/noop)
9
+ # reason - optional human-readable rationale (useful for audit/eval)
10
+ class Decision
11
+ ACTIONS = %i[add update forget noop].freeze
12
+
13
+ attr_reader :action, :candidate, :target_id, :reason
14
+
15
+ def initialize(action:, candidate:, target_id: nil, reason: nil)
16
+ action = action.to_sym
17
+ unless ACTIONS.include?(action)
18
+ raise ArgumentError, "unknown action #{action.inspect}; expected one of #{ACTIONS.inspect}"
19
+ end
20
+
21
+ @action = action
22
+ @candidate = candidate
23
+ @target_id = target_id
24
+ @reason = reason
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ module Extractors
5
+ # Derives durable, user-specific facts from a conversation turn via an LLM.
6
+ class LLMExtractor
7
+ include Ports::Extractor
8
+
9
+ SYSTEM = <<~PROMPT
10
+ You extract durable, user-specific facts worth remembering across future sessions.
11
+ Rules:
12
+ - Only stable facts about the user (preferences, attributes, decisions, history).
13
+ - Ignore ephemeral chit-chat, questions, and the assistant's own messages.
14
+ - Normalize each fact to a terse third-person statement (e.g. "User is on the Pro plan").
15
+ - Set confidence in [0,1]; importance in [0,1].
16
+ Return an empty list if there is nothing worth remembering.
17
+ PROMPT
18
+
19
+ SCHEMA = {
20
+ type: "object",
21
+ properties: {
22
+ facts: {
23
+ type: "array",
24
+ items: {
25
+ type: "object",
26
+ properties: {
27
+ content: {type: "string"},
28
+ kind: {type: "string", enum: %w[semantic episodic preference]},
29
+ importance: {type: "number"},
30
+ confidence: {type: "number"}
31
+ },
32
+ required: %w[content]
33
+ }
34
+ }
35
+ },
36
+ required: %w[facts]
37
+ }.freeze
38
+
39
+ def initialize(completion:, embedder:, min_confidence: 0.5)
40
+ @completion = completion
41
+ @embedder = embedder
42
+ @min_confidence = min_confidence
43
+ end
44
+
45
+ def extract(messages:, scope:)
46
+ result = @completion.complete(system: SYSTEM, user: transcript(messages), schema: SCHEMA)
47
+ facts(result).filter_map do |fact|
48
+ fact = fact.transform_keys(&:to_s)
49
+ content = fact["content"].to_s.strip
50
+ next if content.empty?
51
+ next if (fact["confidence"] || 1.0).to_f < @min_confidence
52
+
53
+ Engram::Record.new(
54
+ content: content,
55
+ scope: scope,
56
+ kind: (fact["kind"] || "semantic").to_sym,
57
+ importance: (fact["importance"] || 1.0).to_f,
58
+ embedding: @embedder.embed(content)
59
+ )
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def facts(result)
66
+ return [] unless result.is_a?(Hash)
67
+
68
+ result["facts"] || result[:facts] || []
69
+ end
70
+
71
+ def transcript(messages)
72
+ Array(messages).map { |m| line(m) }.join("\n")
73
+ end
74
+
75
+ def line(message)
76
+ if message.is_a?(Hash)
77
+ role = message[:role] || message["role"] || "user"
78
+ "#{role}: #{message[:content] || message["content"]}"
79
+ else
80
+ "user: #{message}"
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ module Integrations
5
+ module RubyLLM
6
+ # Wraps a RubyLLM chat so every `ask` is preceded by recall + inject.
7
+ # Experimental in v0.1 — surface may change as the RubyLLM integration matures.
8
+ #
9
+ # chat = Engram.with_memory(RubyLLM.chat, memory: current_user.memory)
10
+ # chat.ask("why am I rate limited?") # recall + inject happen automatically
11
+ class MemoryChat
12
+ def initialize(chat, memory:, limit: Engram.config.default_limit)
13
+ @chat = chat
14
+ @memory = memory
15
+ @limit = limit
16
+ end
17
+
18
+ def ask(message, **opts)
19
+ augmented = @memory.inject_into(message.to_s, query: message.to_s, limit: @limit)
20
+ @chat.ask(augmented, **opts)
21
+ end
22
+
23
+ def method_missing(name, *args, **kwargs, &block)
24
+ return super unless @chat.respond_to?(name)
25
+
26
+ @chat.public_send(name, *args, **kwargs, &block)
27
+ end
28
+
29
+ def respond_to_missing?(name, include_private = false)
30
+ @chat.respond_to?(name, include_private) || super
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+ # Convenience entrypoint.
37
+ def self.with_memory(chat, memory:, limit: config.default_limit)
38
+ Integrations::RubyLLM::MemoryChat.new(chat, memory: memory, limit: limit)
39
+ end
40
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ # Small vector helpers shared by adapters and consolidators.
5
+ module Math
6
+ module_function
7
+
8
+ def cosine_similarity(a, b)
9
+ return 0.0 if a.nil? || b.nil? || a.empty? || b.empty? || a.length != b.length
10
+
11
+ dot = 0.0
12
+ norm_a = 0.0
13
+ norm_b = 0.0
14
+ a.each_index do |i|
15
+ dot += a[i] * b[i]
16
+ norm_a += a[i]**2
17
+ norm_b += b[i]**2
18
+ end
19
+ denom = ::Math.sqrt(norm_a) * ::Math.sqrt(norm_b)
20
+ denom.zero? ? 0.0 : dot / denom
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ # The friendly facade. Bound to one `scope` (an owner), it wires the configured store
5
+ # and embedder into the use cases. This is what `user.memory` returns in Rails.
6
+ class Memory
7
+ attr_reader :scope
8
+
9
+ def initialize(scope:, store: Engram.config.store, embedder: Engram.config.embedder)
10
+ @scope = scope
11
+ @store = store
12
+ @embedder = embedder
13
+ end
14
+
15
+ # Persist a fact. (In v0.2 this is mostly done for you via extract/consolidate.)
16
+ def add(content, kind: :semantic, importance: 1.0, metadata: {})
17
+ record = Record.new(
18
+ content: content,
19
+ scope: scope,
20
+ embedding: @embedder.embed(content),
21
+ kind: kind,
22
+ importance: importance,
23
+ metadata: metadata
24
+ )
25
+ @store.add(record)
26
+ end
27
+
28
+ # Return the most relevant memories for a query.
29
+ def recall(query, limit: Engram.config.default_limit)
30
+ UseCases::Recall.new(
31
+ store: @store,
32
+ embedder: @embedder,
33
+ importance_weight: Engram.config.importance_weight,
34
+ recency_weight: Engram.config.recency_weight,
35
+ recency_halflife: Engram.config.recency_halflife,
36
+ touch: Engram.config.touch_on_recall
37
+ ).call(query, scope: scope, limit: limit)
38
+ end
39
+
40
+ # Recall, then inject into a prompt string.
41
+ def inject_into(prompt, query:, limit: Engram.config.default_limit)
42
+ memories = recall(query, limit: limit)
43
+ UseCases::Inject.new.call(prompt: prompt, memories: memories)
44
+ end
45
+
46
+ # Derive memories from a conversation turn and consolidate them (v0.2).
47
+ # `messages` is an Array of {role:, content:} hashes (or plain strings).
48
+ # Returns the Array<Decision> applied. Requires a configured Completion.
49
+ def observe(messages, completion: Engram.config.completion)
50
+ if completion.nil?
51
+ raise Engram::Error, "observe requires a Completion. Set Engram.config.completion."
52
+ end
53
+
54
+ UseCases::Observe.new(
55
+ store: @store,
56
+ extractor: build_extractor(completion),
57
+ consolidator: build_consolidator(completion),
58
+ processed_turns: Engram.config.processed_turns
59
+ ).call(
60
+ messages: messages,
61
+ scope: scope,
62
+ idempotency_key: TurnDigest.digest(scope: scope, messages: messages)
63
+ )
64
+ end
65
+
66
+ # Enqueue observation as a background job (Rails only).
67
+ def observe_later(messages)
68
+ unless defined?(Engram::ObserveJob)
69
+ raise Engram::Error, "observe_later needs ActiveJob (Rails). Use #observe outside Rails."
70
+ end
71
+
72
+ Engram::ObserveJob.perform_later(scope, messages)
73
+ end
74
+
75
+ def all
76
+ @store.all(scope: scope)
77
+ end
78
+
79
+ # Prune stale memories. `older_than` is a duration in seconds; `min_importance` keeps
80
+ # memories at or above that importance even when old. Returns the forgotten records.
81
+ def forget_stale(older_than:, min_importance: Float::INFINITY)
82
+ UseCases::Forget.new(store: @store)
83
+ .call(scope: scope, older_than: older_than, min_importance: min_importance)
84
+ end
85
+
86
+ private
87
+
88
+ def build_extractor(completion)
89
+ Extractors::LLMExtractor.new(
90
+ completion: completion,
91
+ embedder: @embedder,
92
+ min_confidence: Engram.config.extraction_min_confidence
93
+ )
94
+ end
95
+
96
+ def build_consolidator(completion)
97
+ case Engram.config.consolidator
98
+ when :llm
99
+ Consolidators::LLMConsolidator.new(store: @store, completion: completion)
100
+ else
101
+ Consolidators::HeuristicConsolidator.new(store: @store)
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ module Ports
5
+ # Contract for structured LLM calls used by extraction and consolidation.
6
+ # Implementations: Adapters::RubyLLMCompletion (real), Adapters::FakeCompletion (tests).
7
+ module Completion
8
+ # Run a completion and return parsed structured data conforming to `schema`
9
+ # (a JSON-schema-ish Hash). `system` and `user` are prompt strings.
10
+ def complete(system:, user:, schema:)
11
+ raise NotImplementedError, "#{self.class} must implement #complete"
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ module Ports
5
+ # Contract for reconciling candidate facts against existing memories: decide
6
+ # ADD / UPDATE / FORGET / NOOP per candidate. This is what separates "memory" from a
7
+ # dumb pile of embeddings.
8
+ # Implementations: Consolidators::HeuristicConsolidator, Consolidators::LLMConsolidator.
9
+ module Consolidator
10
+ # Given Array<Record> candidates and a scope, return Array<Decision> (one per
11
+ # candidate that should result in an action).
12
+ def reconcile_all(candidates:, scope:)
13
+ raise NotImplementedError, "#{self.class} must implement #reconcile_all"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ module Ports
5
+ # Contract for turning text into a vector embedding.
6
+ # Implementations: Adapters::NullEmbedder, Adapters::RubyLLMEmbedder.
7
+ module Embedder
8
+ # Return an Array<Float> embedding for `text`.
9
+ def embed(text)
10
+ raise NotImplementedError, "#{self.class} must implement #embed"
11
+ end
12
+
13
+ # Dimensionality of the produced vectors.
14
+ def dimensions
15
+ raise NotImplementedError, "#{self.class} must implement #dimensions"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ module Ports
5
+ # PLACEHOLDER (v0.2). Contract for deriving candidate facts from a conversation turn.
6
+ # Declared now so the differentiator (extract -> consolidate) slots in without
7
+ # reworking the core. Not implemented in v0.1.
8
+ module Extractor
9
+ # Given conversation messages, return Array<Record> of candidate memories.
10
+ def extract(messages:, scope:)
11
+ raise NotImplementedError, "Extractor arrives in v0.2"
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ module Ports
5
+ # Contract for a place memories are persisted and searched.
6
+ # Implementations: Adapters::InMemoryStore, Adapters::PgvectorStore.
7
+ module MemoryStore
8
+ # Persist a Record. Returns the stored Record.
9
+ def add(record)
10
+ raise NotImplementedError, "#{self.class} must implement #add"
11
+ end
12
+
13
+ # Return up to `limit` Records in `scope` nearest to `embedding`,
14
+ # ordered most-relevant first.
15
+ def search(embedding:, scope:, limit:)
16
+ raise NotImplementedError, "#{self.class} must implement #search"
17
+ end
18
+
19
+ # All Records for a scope (mostly for inspection/tests).
20
+ def all(scope:)
21
+ raise NotImplementedError, "#{self.class} must implement #all"
22
+ end
23
+
24
+ # Replace the content/embedding of an existing memory. Used by consolidation
25
+ # (UPDATE). Returns the updated Record.
26
+ def update(id:, record:)
27
+ raise NotImplementedError, "#{self.class} must implement #update"
28
+ end
29
+
30
+ # Remove a memory by id. Used by consolidation (FORGET).
31
+ def delete(id:)
32
+ raise NotImplementedError, "#{self.class} must implement #delete"
33
+ end
34
+
35
+ # Update the last-accessed timestamp of a memory. Used by recency-aware recall.
36
+ def touch(id:, at: Time.now)
37
+ raise NotImplementedError, "#{self.class} must implement #touch"
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ module Ports
5
+ # Contract for remembering which turns have already been observed, so observation is
6
+ # idempotent across retries and accidental double-calls.
7
+ # Implementations: Adapters::InMemoryProcessedTurns, Rails::CacheProcessedTurns.
8
+ module ProcessedTurns
9
+ # Has this idempotency key already been processed?
10
+ def seen?(key)
11
+ raise NotImplementedError, "#{self.class} must implement #seen?"
12
+ end
13
+
14
+ # Mark this idempotency key as processed.
15
+ def record(key)
16
+ raise NotImplementedError, "#{self.class} must implement #record"
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ module Rails
5
+ # ProcessedTurns backed by Rails.cache. Idempotency survives across processes and job
6
+ # retries when a shared cache (e.g. Solid Cache) is configured.
7
+ class CacheProcessedTurns
8
+ include Engram::Ports::ProcessedTurns
9
+
10
+ def initialize(namespace: "engram:processed_turns", ttl: 86_400)
11
+ @namespace = namespace
12
+ @ttl = ttl
13
+ end
14
+
15
+ def seen?(key)
16
+ ::Rails.cache.exist?(cache_key(key))
17
+ end
18
+
19
+ def record(key)
20
+ ::Rails.cache.write(cache_key(key), true, expires_in: @ttl)
21
+ key
22
+ end
23
+
24
+ private
25
+
26
+ def cache_key(key)
27
+ "#{@namespace}:#{key}"
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ module Rails
5
+ # Class-level macro added to ActiveRecord models.
6
+ #
7
+ # class User < ApplicationRecord
8
+ # has_memory # scope => "user:<id>"
9
+ # end
10
+ #
11
+ # class Account < ApplicationRecord
12
+ # has_memory scope: ->{ "team:#{team_id}" }
13
+ # end
14
+ #
15
+ # `user.memory` returns an Engram::Memory bound to that owner.
16
+ module HasMemory
17
+ def has_memory(scope: nil)
18
+ scope_proc = scope
19
+
20
+ define_method(:memory) do
21
+ key =
22
+ if scope_proc
23
+ instance_exec(&scope_proc)
24
+ else
25
+ "#{self.class.name.underscore}:#{id}"
26
+ end
27
+ Engram::Memory.new(scope: key)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ # Background observation: runs extract → consolidate off the request path.
5
+ # Defined only when ActiveJob is available (loaded via the Railtie).
6
+ class ObserveJob < ActiveJob::Base
7
+ def perform(scope, messages)
8
+ Engram::Memory.new(scope: scope).observe(messages)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+ require_relative "rails/cache_processed_turns"
5
+
6
+ module Engram
7
+ # Wires engram into Rails: the `has_memory` macro on ActiveRecord models and the
8
+ # background ObserveJob on ActiveJob. Loaded only when Rails is present (see lib/engram.rb).
9
+ class Railtie < ::Rails::Railtie
10
+ initializer "engram.active_record" do
11
+ ActiveSupport.on_load(:active_record) do
12
+ require "engram/rails/has_memory"
13
+ extend Engram::Rails::HasMemory
14
+ end
15
+ end
16
+
17
+ initializer "engram.active_job" do
18
+ ActiveSupport.on_load(:active_job) do
19
+ require "engram/rails/observe_job"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ # A single unit of memory.
5
+ #
6
+ # `id` is assigned by the store on persistence (nil until then); consolidation uses it
7
+ # to target UPDATE/FORGET. `scope` namespaces memories to an owner (e.g. "user:42").
8
+ # `kind` is a memory type (semantic / episodic / preference).
9
+ class Record
10
+ attr_accessor :id, :last_accessed_at
11
+ attr_reader :content, :embedding, :scope, :kind, :importance, :metadata,
12
+ :created_at
13
+
14
+ def initialize(content:, scope:, id: nil, embedding: nil, kind: :semantic,
15
+ importance: 1.0, metadata: {}, created_at: nil, last_accessed_at: nil)
16
+ @id = id
17
+ @content = content
18
+ @scope = scope
19
+ @embedding = embedding
20
+ @kind = kind
21
+ @importance = importance
22
+ @metadata = metadata
23
+ @created_at = created_at || Time.now
24
+ @last_accessed_at = last_accessed_at
25
+ end
26
+
27
+ def to_h
28
+ {
29
+ id: id, content: content, scope: scope, embedding: embedding, kind: kind,
30
+ importance: importance, metadata: metadata,
31
+ created_at: created_at, last_accessed_at: last_accessed_at
32
+ }
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module Engram
7
+ # Produces a stable digest for a conversation turn (scope + messages). Used as an
8
+ # idempotency key so the same turn is not observed twice.
9
+ module TurnDigest
10
+ module_function
11
+
12
+ def digest(scope:, messages:)
13
+ normalized = Array(messages).map { |message| normalize(message) }
14
+ Digest::SHA256.hexdigest(JSON.generate(scope: scope, messages: normalized))
15
+ end
16
+
17
+ def normalize(message)
18
+ if message.is_a?(Hash)
19
+ {
20
+ role: (message[:role] || message["role"] || "user").to_s,
21
+ content: (message[:content] || message["content"]).to_s
22
+ }
23
+ else
24
+ {role: "user", content: message.to_s}
25
+ end
26
+ end
27
+ end
28
+ end