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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +38 -0
- data/LICENSE.txt +21 -0
- data/README.md +202 -0
- data/lib/engram/adapters/fake_completion.rb +32 -0
- data/lib/engram/adapters/in_memory_processed_turns.rb +29 -0
- data/lib/engram/adapters/in_memory_store.rb +58 -0
- data/lib/engram/adapters/null_embedder.rb +28 -0
- data/lib/engram/adapters/pgvector_store.rb +90 -0
- data/lib/engram/adapters/ruby_llm_completion.rb +43 -0
- data/lib/engram/adapters/ruby_llm_embedder.rb +35 -0
- data/lib/engram/configuration.rb +28 -0
- data/lib/engram/consolidators/heuristic_consolidator.rb +31 -0
- data/lib/engram/consolidators/llm_consolidator.rb +98 -0
- data/lib/engram/decision.rb +27 -0
- data/lib/engram/extractors/llm_extractor.rb +85 -0
- data/lib/engram/integrations/ruby_llm.rb +40 -0
- data/lib/engram/math.rb +23 -0
- data/lib/engram/memory.rb +105 -0
- data/lib/engram/ports/completion.rb +15 -0
- data/lib/engram/ports/consolidator.rb +17 -0
- data/lib/engram/ports/embedder.rb +19 -0
- data/lib/engram/ports/extractor.rb +15 -0
- data/lib/engram/ports/memory_store.rb +41 -0
- data/lib/engram/ports/processed_turns.rb +20 -0
- data/lib/engram/rails/cache_processed_turns.rb +31 -0
- data/lib/engram/rails/has_memory.rb +32 -0
- data/lib/engram/rails/observe_job.rb +11 -0
- data/lib/engram/railtie.rb +23 -0
- data/lib/engram/record.rb +35 -0
- data/lib/engram/turn_digest.rb +28 -0
- data/lib/engram/use_cases/forget.rb +28 -0
- data/lib/engram/use_cases/inject.rb +22 -0
- data/lib/engram/use_cases/observe.rb +59 -0
- data/lib/engram/use_cases/recall.rb +69 -0
- data/lib/engram/version.rb +5 -0
- data/lib/engram.rb +66 -0
- data/lib/generators/engram/install_generator.rb +48 -0
- data/lib/generators/engram/templates/create_engram_memories.rb.tt +24 -0
- data/lib/generators/engram/templates/initializer.rb.tt +12 -0
- data/lib/generators/engram/templates/memory_record.rb.tt +9 -0
- 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
|
data/lib/engram/math.rb
ADDED
|
@@ -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
|