engram 0.3.0 → 0.4.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 +61 -0
- data/README.md +323 -39
- data/lib/engram/adapters/in_memory_store.rb +25 -2
- data/lib/engram/adapters/pgvector_store.rb +33 -4
- data/lib/engram/configuration.rb +5 -1
- data/lib/engram/consolidators/llm_consolidator.rb +7 -2
- data/lib/engram/extractors/llm_extractor.rb +12 -3
- data/lib/engram/instrumentation.rb +57 -0
- data/lib/engram/memory.rb +30 -17
- data/lib/engram/memory_kind.rb +19 -0
- data/lib/engram/persistence.rb +34 -0
- data/lib/engram/persistence_policy.rb +45 -0
- data/lib/engram/ports/memory_store.rb +3 -2
- data/lib/engram/record.rb +8 -3
- data/lib/engram/use_cases/inject.rb +17 -3
- data/lib/engram/use_cases/observe.rb +56 -13
- data/lib/engram/use_cases/recall.rb +18 -7
- data/lib/engram/version.rb +1 -1
- data/lib/engram.rb +4 -0
- data/lib/generators/engram/install_generator.rb +10 -0
- data/lib/generators/engram/templates/create_engram_memories.rb.tt +10 -3
- metadata +9 -4
data/lib/engram/configuration.rb
CHANGED
|
@@ -7,7 +7,8 @@ module Engram
|
|
|
7
7
|
class Configuration
|
|
8
8
|
attr_accessor :store, :embedder, :completion, :default_limit,
|
|
9
9
|
:consolidator, :extraction_min_confidence, :processed_turns,
|
|
10
|
-
:importance_weight, :recency_weight, :recency_halflife, :touch_on_recall
|
|
10
|
+
:importance_weight, :recency_weight, :recency_halflife, :touch_on_recall,
|
|
11
|
+
:persistence_policy, :before_persist, :instrumentation_scope_identifier
|
|
11
12
|
|
|
12
13
|
def initialize
|
|
13
14
|
@store = Adapters::InMemoryStore.new
|
|
@@ -17,6 +18,9 @@ module Engram
|
|
|
17
18
|
@consolidator = :heuristic # :heuristic (deterministic) or :llm (LLM-as-judge)
|
|
18
19
|
@extraction_min_confidence = 0.5
|
|
19
20
|
@processed_turns = Adapters::InMemoryProcessedTurns.new # idempotency for observe
|
|
21
|
+
@persistence_policy = PersistencePolicy.new
|
|
22
|
+
@before_persist = nil
|
|
23
|
+
@instrumentation_scope_identifier = nil
|
|
20
24
|
|
|
21
25
|
# Recall ranking. With both weights at 0.0, recall is plain similarity search.
|
|
22
26
|
@importance_weight = 0.0
|
|
@@ -21,20 +21,25 @@ module Engram
|
|
|
21
21
|
Return one decision per candidate, referencing it by its index.
|
|
22
22
|
PROMPT
|
|
23
23
|
|
|
24
|
+
# Shaped for OpenAI strict structured outputs: every object sets
|
|
25
|
+
# additionalProperties: false and lists all of its properties in `required`. target_id
|
|
26
|
+
# is nullable because add/noop decisions reference no existing memory.
|
|
24
27
|
SCHEMA = {
|
|
25
28
|
type: "object",
|
|
29
|
+
additionalProperties: false,
|
|
26
30
|
properties: {
|
|
27
31
|
decisions: {
|
|
28
32
|
type: "array",
|
|
29
33
|
items: {
|
|
30
34
|
type: "object",
|
|
35
|
+
additionalProperties: false,
|
|
31
36
|
properties: {
|
|
32
37
|
index: {type: "integer"},
|
|
33
38
|
action: {type: "string", enum: %w[add update forget noop]},
|
|
34
|
-
target_id: {type: %w[integer
|
|
39
|
+
target_id: {type: %w[integer null]},
|
|
35
40
|
reason: {type: "string"}
|
|
36
41
|
},
|
|
37
|
-
required: %w[index action]
|
|
42
|
+
required: %w[index action target_id reason]
|
|
38
43
|
}
|
|
39
44
|
}
|
|
40
45
|
},
|
|
@@ -12,24 +12,32 @@ module Engram
|
|
|
12
12
|
- Only stable facts about the user (preferences, attributes, decisions, history).
|
|
13
13
|
- Ignore ephemeral chit-chat, questions, and the assistant's own messages.
|
|
14
14
|
- Normalize each fact to a terse third-person statement (e.g. "User is on the Pro plan").
|
|
15
|
+
- Classify kind as fact, preference, instruction, or episodic.
|
|
16
|
+
- Do not extract secrets, API keys, passwords, tokens, or transient task progress.
|
|
15
17
|
- Set confidence in [0,1]; importance in [0,1].
|
|
16
18
|
Return an empty list if there is nothing worth remembering.
|
|
17
19
|
PROMPT
|
|
18
20
|
|
|
21
|
+
# Shaped for OpenAI strict structured outputs: every object sets
|
|
22
|
+
# additionalProperties: false and lists all of its properties in `required`. The
|
|
23
|
+
# extractor still defends against missing/empty fields, so requiring them here only
|
|
24
|
+
# constrains the model's output, it does not change downstream behaviour.
|
|
19
25
|
SCHEMA = {
|
|
20
26
|
type: "object",
|
|
27
|
+
additionalProperties: false,
|
|
21
28
|
properties: {
|
|
22
29
|
facts: {
|
|
23
30
|
type: "array",
|
|
24
31
|
items: {
|
|
25
32
|
type: "object",
|
|
33
|
+
additionalProperties: false,
|
|
26
34
|
properties: {
|
|
27
35
|
content: {type: "string"},
|
|
28
|
-
kind: {type: "string", enum: %w[
|
|
36
|
+
kind: {type: "string", enum: %w[fact preference instruction episodic semantic]},
|
|
29
37
|
importance: {type: "number"},
|
|
30
38
|
confidence: {type: "number"}
|
|
31
39
|
},
|
|
32
|
-
required: %w[content]
|
|
40
|
+
required: %w[content kind importance confidence]
|
|
33
41
|
}
|
|
34
42
|
}
|
|
35
43
|
},
|
|
@@ -53,8 +61,9 @@ module Engram
|
|
|
53
61
|
Engram::Record.new(
|
|
54
62
|
content: content,
|
|
55
63
|
scope: scope,
|
|
56
|
-
kind:
|
|
64
|
+
kind: fact["kind"] || "fact",
|
|
57
65
|
importance: (fact["importance"] || 1.0).to_f,
|
|
66
|
+
metadata: {confidence: (fact["confidence"] || 1.0).to_f},
|
|
58
67
|
embedding: @embedder.embed(content)
|
|
59
68
|
)
|
|
60
69
|
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Engram
|
|
4
|
+
# Optional ActiveSupport::Notifications integration.
|
|
5
|
+
#
|
|
6
|
+
# Engram core remains dependency-free: when ActiveSupport is not loaded, instrumentation
|
|
7
|
+
# is a no-op around the supplied block.
|
|
8
|
+
module Instrumentation
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def instrument(event, payload = {})
|
|
12
|
+
started_at = monotonic_time
|
|
13
|
+
|
|
14
|
+
unless notifications?
|
|
15
|
+
return yield if block_given?
|
|
16
|
+
return nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
ActiveSupport::Notifications.instrument("#{event}.engram", payload) do
|
|
20
|
+
yield if block_given?
|
|
21
|
+
ensure
|
|
22
|
+
payload[:duration_ms] = elapsed_ms(started_at)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def payload(scope: nil, store: nil, **attributes)
|
|
27
|
+
attributes = attributes.compact
|
|
28
|
+
attributes[:store_adapter] = adapter_name(store) if store
|
|
29
|
+
scope_identifier = scope_identifier(scope)
|
|
30
|
+
attributes[:scope_identifier] = scope_identifier if scope_identifier
|
|
31
|
+
attributes
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def notifications?
|
|
35
|
+
defined?(ActiveSupport::Notifications) && ActiveSupport::Notifications.respond_to?(:instrument)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def adapter_name(adapter)
|
|
39
|
+
adapter.class.name || adapter.class.to_s
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def scope_identifier(scope)
|
|
43
|
+
formatter = Engram.config.instrumentation_scope_identifier
|
|
44
|
+
return nil unless formatter
|
|
45
|
+
|
|
46
|
+
formatter.respond_to?(:call) ? formatter.call(scope) : scope.to_s
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def elapsed_ms(started_at)
|
|
50
|
+
((monotonic_time - started_at) * 1000).round(1)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def monotonic_time
|
|
54
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
data/lib/engram/memory.rb
CHANGED
|
@@ -12,21 +12,24 @@ module Engram
|
|
|
12
12
|
@embedder = embedder
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
# Persist a
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
15
|
+
# Persist a memory record of the given kind. Returns nil when the configured
|
|
16
|
+
# persistence policy rejects the record.
|
|
17
|
+
def add(content, kind: :fact, importance: 1.0, metadata: {})
|
|
18
|
+
Engram::Instrumentation.instrument("add", Engram::Instrumentation.payload(scope: scope, store: @store, kind: kind)) do
|
|
19
|
+
record = Record.new(
|
|
20
|
+
content: content,
|
|
21
|
+
scope: scope,
|
|
22
|
+
embedding: @embedder.embed(content),
|
|
23
|
+
kind: kind,
|
|
24
|
+
importance: importance,
|
|
25
|
+
metadata: metadata
|
|
26
|
+
)
|
|
27
|
+
persist(record)
|
|
28
|
+
end
|
|
26
29
|
end
|
|
27
30
|
|
|
28
31
|
# Return the most relevant memories for a query.
|
|
29
|
-
def recall(query, limit: Engram.config.default_limit)
|
|
32
|
+
def recall(query, limit: Engram.config.default_limit, kinds: nil)
|
|
30
33
|
UseCases::Recall.new(
|
|
31
34
|
store: @store,
|
|
32
35
|
embedder: @embedder,
|
|
@@ -34,12 +37,12 @@ module Engram
|
|
|
34
37
|
recency_weight: Engram.config.recency_weight,
|
|
35
38
|
recency_halflife: Engram.config.recency_halflife,
|
|
36
39
|
touch: Engram.config.touch_on_recall
|
|
37
|
-
).call(query, scope: scope, limit: limit)
|
|
40
|
+
).call(query, scope: scope, limit: limit, kinds: kinds)
|
|
38
41
|
end
|
|
39
42
|
|
|
40
43
|
# Recall, then inject into a prompt string.
|
|
41
|
-
def inject_into(prompt, query:, limit: Engram.config.default_limit)
|
|
42
|
-
memories = recall(query, limit: limit)
|
|
44
|
+
def inject_into(prompt, query:, limit: Engram.config.default_limit, kinds: nil)
|
|
45
|
+
memories = recall(query, limit: limit, kinds: kinds)
|
|
43
46
|
UseCases::Inject.new.call(prompt: prompt, memories: memories)
|
|
44
47
|
end
|
|
45
48
|
|
|
@@ -55,7 +58,8 @@ module Engram
|
|
|
55
58
|
store: @store,
|
|
56
59
|
extractor: build_extractor(completion),
|
|
57
60
|
consolidator: build_consolidator(completion),
|
|
58
|
-
processed_turns: Engram.config.processed_turns
|
|
61
|
+
processed_turns: Engram.config.processed_turns,
|
|
62
|
+
embedder: @embedder
|
|
59
63
|
).call(
|
|
60
64
|
messages: messages,
|
|
61
65
|
scope: scope,
|
|
@@ -69,7 +73,12 @@ module Engram
|
|
|
69
73
|
raise Engram::Error, "observe_later needs ActiveJob (Rails). Use #observe outside Rails."
|
|
70
74
|
end
|
|
71
75
|
|
|
72
|
-
Engram::
|
|
76
|
+
Engram::Instrumentation.instrument(
|
|
77
|
+
"observe_later",
|
|
78
|
+
Engram::Instrumentation.payload(scope: scope, store: @store, message_count: messages.size)
|
|
79
|
+
) do
|
|
80
|
+
Engram::ObserveJob.perform_later(scope, messages)
|
|
81
|
+
end
|
|
73
82
|
end
|
|
74
83
|
|
|
75
84
|
def all
|
|
@@ -85,6 +94,10 @@ module Engram
|
|
|
85
94
|
|
|
86
95
|
private
|
|
87
96
|
|
|
97
|
+
def persist(record)
|
|
98
|
+
Persistence.new(store: @store, embedder: @embedder).add(record)
|
|
99
|
+
end
|
|
100
|
+
|
|
88
101
|
def build_extractor(completion)
|
|
89
102
|
Extractors::LLMExtractor.new(
|
|
90
103
|
completion: completion,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Engram
|
|
4
|
+
# Canonical memory categories used by extraction, recall, and policy.
|
|
5
|
+
module MemoryKind
|
|
6
|
+
VALID = %i[fact preference instruction episodic].freeze
|
|
7
|
+
LEGACY_ALIASES = {semantic: :fact}.freeze
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def normalize(kind)
|
|
12
|
+
normalized = kind.to_s.strip.downcase.to_sym
|
|
13
|
+
normalized = LEGACY_ALIASES.fetch(normalized, normalized)
|
|
14
|
+
return normalized if VALID.include?(normalized)
|
|
15
|
+
|
|
16
|
+
raise ArgumentError, "unknown memory kind #{kind.inspect}; expected one of #{VALID.join(", ")}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Engram
|
|
4
|
+
# Applies persistence hooks and policy consistently before writing records.
|
|
5
|
+
class Persistence
|
|
6
|
+
def initialize(store:, embedder:, before_persist: Engram.config.before_persist,
|
|
7
|
+
persistence_policy: Engram.config.persistence_policy)
|
|
8
|
+
@store = store
|
|
9
|
+
@embedder = embedder
|
|
10
|
+
@before_persist = before_persist
|
|
11
|
+
@persistence_policy = persistence_policy
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def add(record)
|
|
15
|
+
record = prepare(record)
|
|
16
|
+
@store.add(record) if record
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def update(id:, record:)
|
|
20
|
+
record = prepare(record)
|
|
21
|
+
@store.update(id: id, record: record) if record
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def prepare(record)
|
|
27
|
+
original_content = record.content
|
|
28
|
+
record = @before_persist.call(record) if @before_persist
|
|
29
|
+
record = @persistence_policy.call(record) if record && @persistence_policy
|
|
30
|
+
record = record.with(embedding: @embedder.embed(record.content)) if record && record.content != original_content
|
|
31
|
+
record
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Engram
|
|
4
|
+
# Default gate applied before memories are persisted. It keeps obvious secrets and
|
|
5
|
+
# transient task-progress updates out of durable memory, and can redact caller-supplied
|
|
6
|
+
# denylist patterns before storage.
|
|
7
|
+
class PersistencePolicy
|
|
8
|
+
SECRET_PATTERNS = [
|
|
9
|
+
/\b(?:api[_ -]?key|token|secret|password)\b\s*(?:is|=|:)\s+(?=\S*[0-9_-])\S{8,}/i,
|
|
10
|
+
/\bsk-[A-Za-z0-9_-]{6,}\b/,
|
|
11
|
+
/\b(?:ghp|github_pat)_[A-Za-z0-9_]{10,}\b/
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
TRANSIENT_PATTERNS = [
|
|
15
|
+
/\b(?:fixed|resolved|done|completed|finished)\b.*\b(?:today|now|this session)\b/i,
|
|
16
|
+
/\b(?:today|now|this session)\b.*\b(?:fixed|resolved|done|completed|finished)\b/i
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
def initialize(denylist_patterns: [])
|
|
20
|
+
@denylist_patterns = denylist_patterns
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call(record)
|
|
24
|
+
return nil if reject?(record.content)
|
|
25
|
+
|
|
26
|
+
redact(record)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def reject?(content)
|
|
32
|
+
SECRET_PATTERNS.any? { |pattern| content.match?(pattern) } ||
|
|
33
|
+
TRANSIENT_PATTERNS.any? { |pattern| content.match?(pattern) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def redact(record)
|
|
37
|
+
redacted = @denylist_patterns.reduce(record.content) do |content, pattern|
|
|
38
|
+
content.gsub(pattern, "[REDACTED]")
|
|
39
|
+
end
|
|
40
|
+
return record if redacted == record.content
|
|
41
|
+
|
|
42
|
+
record.with(content: redacted)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -11,8 +11,9 @@ module Engram
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
# Return up to `limit` Records in `scope` nearest to `embedding`,
|
|
14
|
-
# ordered most-relevant first.
|
|
15
|
-
|
|
14
|
+
# ordered most-relevant first. When `kinds` is provided, only records with
|
|
15
|
+
# those canonical memory kinds are eligible.
|
|
16
|
+
def search(embedding:, scope:, limit:, kinds: nil)
|
|
16
17
|
raise NotImplementedError, "#{self.class} must implement #search"
|
|
17
18
|
end
|
|
18
19
|
|
data/lib/engram/record.rb
CHANGED
|
@@ -5,25 +5,30 @@ module Engram
|
|
|
5
5
|
#
|
|
6
6
|
# `id` is assigned by the store on persistence (nil until then); consolidation uses it
|
|
7
7
|
# to target UPDATE/FORGET. `scope` namespaces memories to an owner (e.g. "user:42").
|
|
8
|
-
# `kind` is a memory type (
|
|
8
|
+
# `kind` is a memory type (fact / preference / instruction / episodic). The legacy
|
|
9
|
+
# `semantic` kind is normalized to `fact` for compatibility with pre-1.0 records.
|
|
9
10
|
class Record
|
|
10
11
|
attr_accessor :id, :last_accessed_at
|
|
11
12
|
attr_reader :content, :embedding, :scope, :kind, :importance, :metadata,
|
|
12
13
|
:created_at
|
|
13
14
|
|
|
14
|
-
def initialize(content:, scope:, id: nil, embedding: nil, kind: :
|
|
15
|
+
def initialize(content:, scope:, id: nil, embedding: nil, kind: :fact,
|
|
15
16
|
importance: 1.0, metadata: {}, created_at: nil, last_accessed_at: nil)
|
|
16
17
|
@id = id
|
|
17
18
|
@content = content
|
|
18
19
|
@scope = scope
|
|
19
20
|
@embedding = embedding
|
|
20
|
-
@kind = kind
|
|
21
|
+
@kind = MemoryKind.normalize(kind)
|
|
21
22
|
@importance = importance
|
|
22
23
|
@metadata = metadata
|
|
23
24
|
@created_at = created_at || Time.now
|
|
24
25
|
@last_accessed_at = last_accessed_at
|
|
25
26
|
end
|
|
26
27
|
|
|
28
|
+
def with(**attributes)
|
|
29
|
+
self.class.new(**to_h.merge(attributes))
|
|
30
|
+
end
|
|
31
|
+
|
|
27
32
|
def to_h
|
|
28
33
|
{
|
|
29
34
|
id: id, content: content, scope: scope, embedding: embedding, kind: kind,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
3
5
|
module Engram
|
|
4
6
|
module UseCases
|
|
5
7
|
# Render recalled memories into a prompt as a clearly delimited block.
|
|
@@ -12,10 +14,22 @@ module Engram
|
|
|
12
14
|
|
|
13
15
|
# Returns a new prompt string. If there are no memories, the prompt is unchanged.
|
|
14
16
|
def call(prompt:, memories:)
|
|
15
|
-
|
|
17
|
+
payload = {memory_count: memories&.size.to_i}
|
|
18
|
+
Engram::Instrumentation.instrument("inject", payload) do
|
|
19
|
+
next prompt if memories.nil? || memories.empty?
|
|
20
|
+
|
|
21
|
+
block = memories.map { |memory| render_memory(memory) }.join("\n")
|
|
22
|
+
"#{prompt}\n\n#{@header}:\n<engram-memories>\n#{block}\n</engram-memories>"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def render_memory(memory)
|
|
29
|
+
kind = CGI.escapeHTML((memory.kind || :fact).to_s)
|
|
30
|
+
content = CGI.escapeHTML(memory.content.to_s)
|
|
16
31
|
|
|
17
|
-
|
|
18
|
-
"#{prompt}\n\n#{@header}:\n#{block}"
|
|
32
|
+
%(<engram-memory kind="#{kind}">#{content}</engram-memory>)
|
|
19
33
|
end
|
|
20
34
|
end
|
|
21
35
|
end
|
|
@@ -9,27 +9,45 @@ module Engram
|
|
|
9
9
|
# When a ProcessedTurns store and an idempotency_key are provided, a turn that was
|
|
10
10
|
# already processed is skipped (no extraction, no duplicate memories).
|
|
11
11
|
class Observe
|
|
12
|
-
def initialize(store:, extractor:, consolidator:, processed_turns: nil)
|
|
12
|
+
def initialize(store:, extractor:, consolidator:, processed_turns: nil, embedder: Engram.config.embedder)
|
|
13
13
|
@store = store
|
|
14
14
|
@extractor = extractor
|
|
15
15
|
@consolidator = consolidator
|
|
16
16
|
@processed_turns = processed_turns
|
|
17
|
+
@embedder = embedder
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
# Returns the Array<Decision> that were applied (empty if skipped or nothing found).
|
|
20
21
|
def call(messages:, scope:, idempotency_key: nil)
|
|
21
|
-
|
|
22
|
+
payload = Engram::Instrumentation.payload(
|
|
23
|
+
scope: scope,
|
|
24
|
+
store: @store,
|
|
25
|
+
message_count: messages.size,
|
|
26
|
+
idempotency_key_present: !idempotency_key.nil?
|
|
27
|
+
)
|
|
28
|
+
Engram::Instrumentation.instrument("observe", payload) do
|
|
29
|
+
if already_processed?(idempotency_key)
|
|
30
|
+
payload[:skipped] = true
|
|
31
|
+
payload[:candidate_count] = 0
|
|
32
|
+
payload[:decision_count] = 0
|
|
33
|
+
next []
|
|
34
|
+
end
|
|
22
35
|
|
|
23
|
-
|
|
24
|
-
|
|
36
|
+
candidates = extract(messages: messages, scope: scope)
|
|
37
|
+
payload[:candidate_count] = candidates.size
|
|
38
|
+
if candidates.empty?
|
|
39
|
+
mark_processed(idempotency_key)
|
|
40
|
+
payload[:decision_count] = 0
|
|
41
|
+
next []
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
decisions = consolidate(candidates: candidates, scope: scope)
|
|
45
|
+
applied_decisions = decisions.filter_map { |decision| apply(decision) }
|
|
46
|
+
payload[:decision_count] = applied_decisions.size
|
|
47
|
+
payload[:decision_actions] = applied_decisions.map { |decision| decision.action.to_s }
|
|
25
48
|
mark_processed(idempotency_key)
|
|
26
|
-
|
|
49
|
+
applied_decisions
|
|
27
50
|
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
51
|
end
|
|
34
52
|
|
|
35
53
|
private
|
|
@@ -42,18 +60,43 @@ module Engram
|
|
|
42
60
|
@processed_turns.record(key) if key && @processed_turns
|
|
43
61
|
end
|
|
44
62
|
|
|
63
|
+
def extract(messages:, scope:)
|
|
64
|
+
payload = Engram::Instrumentation.payload(scope: scope, store: @store, message_count: messages.size)
|
|
65
|
+
Engram::Instrumentation.instrument("extract", payload) do
|
|
66
|
+
candidates = @extractor.extract(messages: messages, scope: scope)
|
|
67
|
+
payload[:candidate_count] = candidates.size
|
|
68
|
+
candidates
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def consolidate(candidates:, scope:)
|
|
73
|
+
payload = Engram::Instrumentation.payload(scope: scope, store: @store, candidate_count: candidates.size)
|
|
74
|
+
Engram::Instrumentation.instrument("consolidate", payload) do
|
|
75
|
+
decisions = @consolidator.reconcile_all(candidates: candidates, scope: scope)
|
|
76
|
+
payload[:decision_count] = decisions.size
|
|
77
|
+
payload[:decision_actions] = decisions.map { |decision| decision.action.to_s }
|
|
78
|
+
decisions
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
45
82
|
def apply(decision)
|
|
46
83
|
case decision.action
|
|
47
84
|
when :add
|
|
48
|
-
|
|
85
|
+
decision if persistence.add(decision.candidate)
|
|
49
86
|
when :update
|
|
50
|
-
|
|
87
|
+
if decision.target_id && persistence.update(id: decision.target_id, record: decision.candidate)
|
|
88
|
+
decision
|
|
89
|
+
end
|
|
51
90
|
when :forget
|
|
52
|
-
@store.delete(id: decision.target_id)
|
|
91
|
+
decision if decision.target_id && @store.delete(id: decision.target_id)
|
|
53
92
|
when :noop
|
|
54
93
|
nil
|
|
55
94
|
end
|
|
56
95
|
end
|
|
96
|
+
|
|
97
|
+
def persistence
|
|
98
|
+
@persistence ||= Persistence.new(store: @store, embedder: @embedder)
|
|
99
|
+
end
|
|
57
100
|
end
|
|
58
101
|
end
|
|
59
102
|
end
|
|
@@ -24,16 +24,27 @@ module Engram
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
# Returns Array<Record>, most relevant first.
|
|
27
|
-
def call(query, scope:, limit: Engram.config.default_limit)
|
|
27
|
+
def call(query, scope:, limit: Engram.config.default_limit, kinds: nil)
|
|
28
28
|
raise ArgumentError, "query must be a non-empty string" if query.to_s.strip.empty?
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
payload = Engram::Instrumentation.payload(
|
|
31
|
+
scope: scope,
|
|
32
|
+
store: @store,
|
|
33
|
+
limit: limit,
|
|
34
|
+
kinds: Array(kinds).map(&:to_s),
|
|
35
|
+
reranking: reranking?
|
|
36
|
+
)
|
|
37
|
+
Engram::Instrumentation.instrument("recall", payload) do
|
|
38
|
+
embedding = @embedder.embed(query)
|
|
39
|
+
pool_limit = reranking? ? limit * @pool_factor : limit
|
|
40
|
+
pool = @store.search(embedding: embedding, scope: scope, limit: pool_limit, kinds: kinds)
|
|
33
41
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
42
|
+
results = (reranking? ? rerank(pool, embedding) : pool).first(limit)
|
|
43
|
+
touch(results) if @touch
|
|
44
|
+
payload[:result_count] = results.size
|
|
45
|
+
payload[:candidate_count] = pool.size
|
|
46
|
+
results
|
|
47
|
+
end
|
|
37
48
|
end
|
|
38
49
|
|
|
39
50
|
private
|
data/lib/engram/version.rb
CHANGED
data/lib/engram.rb
CHANGED
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
require_relative "engram/version"
|
|
4
4
|
require_relative "engram/configuration"
|
|
5
5
|
require_relative "engram/math"
|
|
6
|
+
require_relative "engram/memory_kind"
|
|
6
7
|
require_relative "engram/record"
|
|
7
8
|
require_relative "engram/decision"
|
|
8
9
|
require_relative "engram/turn_digest"
|
|
10
|
+
require_relative "engram/persistence_policy"
|
|
11
|
+
require_relative "engram/instrumentation"
|
|
12
|
+
require_relative "engram/persistence"
|
|
9
13
|
|
|
10
14
|
# Ports (contracts)
|
|
11
15
|
require_relative "engram/ports/memory_store"
|
|
@@ -17,6 +17,10 @@ module Engram
|
|
|
17
17
|
class_option :dimensions, type: :numeric, default: DEFAULT_DIMENSIONS,
|
|
18
18
|
desc: "Embedding dimensions (match your embedding model)"
|
|
19
19
|
|
|
20
|
+
def validate_dimensions
|
|
21
|
+
validate_dimensions!
|
|
22
|
+
end
|
|
23
|
+
|
|
20
24
|
def create_migration_file
|
|
21
25
|
migration_template "create_engram_memories.rb.tt",
|
|
22
26
|
"db/migrate/create_engram_memories.rb"
|
|
@@ -40,6 +44,12 @@ module Engram
|
|
|
40
44
|
options[:dimensions]
|
|
41
45
|
end
|
|
42
46
|
|
|
47
|
+
def validate_dimensions!
|
|
48
|
+
return if dimensions.is_a?(Integer) && dimensions.positive?
|
|
49
|
+
|
|
50
|
+
raise ArgumentError, "dimensions must be a positive integer that matches your embedding model"
|
|
51
|
+
end
|
|
52
|
+
|
|
43
53
|
def migration_version
|
|
44
54
|
"#{::ActiveRecord::VERSION::MAJOR}.#{::ActiveRecord::VERSION::MINOR}"
|
|
45
55
|
end
|
|
@@ -7,7 +7,7 @@ class CreateEngramMemories < ActiveRecord::Migration[<%= migration_version %>]
|
|
|
7
7
|
create_table :engram_memories do |t|
|
|
8
8
|
t.string :scope, null: false
|
|
9
9
|
t.text :content, null: false
|
|
10
|
-
t.string :kind, null: false, default: "
|
|
10
|
+
t.string :kind, null: false, default: "fact"
|
|
11
11
|
t.float :importance, null: false, default: 1.0
|
|
12
12
|
t.jsonb :metadata, null: false, default: {}
|
|
13
13
|
t.vector :embedding, limit: <%= dimensions %>
|
|
@@ -18,7 +18,14 @@ class CreateEngramMemories < ActiveRecord::Migration[<%= migration_version %>]
|
|
|
18
18
|
|
|
19
19
|
add_index :engram_memories, :scope
|
|
20
20
|
|
|
21
|
-
# For larger datasets,
|
|
22
|
-
#
|
|
21
|
+
# For larger datasets, choose one approximate vector index after backfilling data.
|
|
22
|
+
# HNSW is a strong default for read-heavy recall workloads with frequent inserts.
|
|
23
|
+
# IVFFlat can be smaller/faster to build, but should be created after enough representative data exists.
|
|
24
|
+
#
|
|
25
|
+
# HNSW:
|
|
26
|
+
# add_index :engram_memories, :embedding, using: :hnsw, opclass: :vector_cosine_ops
|
|
27
|
+
# IVFFlat:
|
|
28
|
+
# add_index :engram_memories, :embedding, using: :ivfflat, opclass: :vector_cosine_ops
|
|
29
|
+
# See the Engram README for production index guidance.
|
|
23
30
|
end
|
|
24
31
|
end
|