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.
@@ -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 string null]},
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[semantic episodic preference]},
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: (fact["kind"] || "semantic").to_sym,
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 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)
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::ObserveJob.perform_later(scope, messages)
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
- def search(embedding:, scope:, limit:)
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 (semantic / episodic / preference).
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: :semantic,
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
- return prompt if memories.nil? || memories.empty?
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
- block = memories.map { |m| "- #{m.content}" }.join("\n")
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
- return [] if already_processed?(idempotency_key)
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
- candidates = @extractor.extract(messages: messages, scope: scope)
24
- if candidates.empty?
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
- return []
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
- @store.add(decision.candidate)
85
+ decision if persistence.add(decision.candidate)
49
86
  when :update
50
- @store.update(id: decision.target_id, record: decision.candidate) if decision.target_id
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) if 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
- embedding = @embedder.embed(query)
31
- pool_limit = reranking? ? limit * @pool_factor : limit
32
- pool = @store.search(embedding: embedding, scope: scope, limit: pool_limit)
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
- results = (reranking? ? rerank(pool, embedding) : pool).first(limit)
35
- touch(results) if @touch
36
- results
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Engram
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
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: "semantic"
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, add an approximate index (requires data present first):
22
- # add_index :engram_memories, :embedding, using: :hnsw, opclass: :vector_cosine_ops
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