llmemory 0.2.0 → 0.2.1

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +7 -2
  3. data/lib/generators/llmemory/install/templates/create_llmemory_tables.rb +20 -0
  4. data/lib/llmemory/cli/commands/base.rb +8 -0
  5. data/lib/llmemory/cli/commands/episodic.rb +42 -0
  6. data/lib/llmemory/cli/commands/forget_log.rb +36 -0
  7. data/lib/llmemory/cli/commands/procedural.rb +44 -0
  8. data/lib/llmemory/cli/commands/working.rb +31 -0
  9. data/lib/llmemory/cli.rb +12 -0
  10. data/lib/llmemory/configuration.rb +4 -0
  11. data/lib/llmemory/long_term/episodic/memory.rb +71 -16
  12. data/lib/llmemory/long_term/episodic/storage.rb +7 -5
  13. data/lib/llmemory/long_term/episodic/storages/active_record_models.rb +17 -0
  14. data/lib/llmemory/long_term/episodic/storages/active_record_storage.rb +93 -0
  15. data/lib/llmemory/long_term/episodic/storages/database_storage.rb +135 -0
  16. data/lib/llmemory/long_term/episodic/storages/file_storage.rb +2 -3
  17. data/lib/llmemory/long_term/episodic/storages/memory_storage.rb +2 -3
  18. data/lib/llmemory/long_term/file_based/storages/active_record_storage.rb +11 -4
  19. data/lib/llmemory/long_term/file_based/storages/database_storage.rb +16 -6
  20. data/lib/llmemory/long_term/file_based/storages/file_storage.rb +2 -4
  21. data/lib/llmemory/long_term/file_based/storages/memory_storage.rb +2 -4
  22. data/lib/llmemory/long_term/graph_based/memory.rb +77 -60
  23. data/lib/llmemory/long_term/procedural/memory.rb +71 -17
  24. data/lib/llmemory/long_term/procedural/storage.rb +7 -5
  25. data/lib/llmemory/long_term/procedural/storages/active_record_models.rb +17 -0
  26. data/lib/llmemory/long_term/procedural/storages/active_record_storage.rb +104 -0
  27. data/lib/llmemory/long_term/procedural/storages/database_storage.rb +148 -0
  28. data/lib/llmemory/long_term/procedural/storages/file_storage.rb +2 -3
  29. data/lib/llmemory/long_term/procedural/storages/memory_storage.rb +2 -3
  30. data/lib/llmemory/mcp/server.rb +13 -1
  31. data/lib/llmemory/mcp/tools/memory_episode_record.rb +48 -0
  32. data/lib/llmemory/mcp/tools/memory_episodes.rb +43 -0
  33. data/lib/llmemory/mcp/tools/memory_forget.rb +53 -0
  34. data/lib/llmemory/mcp/tools/memory_retrieve.rb +10 -2
  35. data/lib/llmemory/mcp/tools/memory_skill_register.rb +35 -0
  36. data/lib/llmemory/mcp/tools/memory_skill_report.rb +35 -0
  37. data/lib/llmemory/mcp/tools/memory_skills.rb +43 -0
  38. data/lib/llmemory/memory.rb +28 -3
  39. data/lib/llmemory/retrieval/bm25_scorer.rb +1 -1
  40. data/lib/llmemory/retrieval/mmr_reranker.rb +1 -1
  41. data/lib/llmemory/short_term/session_lifecycle.rb +19 -3
  42. data/lib/llmemory/tokenizer.rb +27 -0
  43. data/lib/llmemory/vector_store/active_record_store.rb +4 -3
  44. data/lib/llmemory/vector_store.rb +14 -0
  45. data/lib/llmemory/version.rb +1 -1
  46. data/lib/llmemory.rb +1 -0
  47. metadata +18 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c302656888e6373faedb5732525f76d118fd98fb67ece22278d886faec5ba3cd
4
- data.tar.gz: ec0897226ec378e51e86e01cb66e938fbeea3db7e4c49911d7e3d08a08959d07
3
+ metadata.gz: 80f259aa60090b95d21110bdf1f91c2c91bd5334a1bc4e3effaec444f241f371
4
+ data.tar.gz: 0b07d9a6ce5e485a69f00dfb2ad7c0cd03a442e95817d310a65d85e40f9def2c
5
5
  SHA512:
6
- metadata.gz: 6cee9a244e42f198b9fd491d164d85d2524a30d6cf9ca0b4a4da3f1a012f7e2b4643ba1e4dd6838ba7ce32517d5425b77f62df1cb1334ac34d162a273cd6400f
7
- data.tar.gz: 6d46031eda16a52b7c8b42b364dc5351a0eb18f0eeaa135271a3976a0ebba39db439e74fafa981b9cea062d70e2bd006b4576ccaccfff29bfd6ec6fe8011a411
6
+ metadata.gz: 464cc0765650869996ff24a4bf7e2b3ce4d3433a6c73cc9d63167173b04d14deedf0e169ca4579a7d7ac253b5a858f2b88629ce1b888bf48becfa46fad581979
7
+ data.tar.gz: 7624c8cd607cf882b13d500ee878b5412f49e13282014a33dc836cfa68c9bfdf9f759b89f53b51eccb38c32966f0cc66ae5d23a2297f2c965a67d81ccb8f437f
data/README.md CHANGED
@@ -74,6 +74,11 @@ Llmemory.configure do |config|
74
74
  config.importance_weight = 1.0 # how strongly importance multiplies the score (0 = ignore)
75
75
  config.retrieval_feedback_weight = 0.5 # how strongly useful/harmful feedback shifts ranking (0 = ignore)
76
76
 
77
+ # Semantic (embedding) retrieval for episodic/procedural memory (opt-in;
78
+ # default off keeps them deterministic and network-free)
79
+ config.episodic_vector_enabled = false
80
+ config.procedural_vector_enabled = false
81
+
77
82
  # Pre-compaction memory flush (prevents knowledge loss when compacting)
78
83
  config.memory_flush_enabled = true
79
84
  config.memory_flush_threshold_tokens = 4000
@@ -129,7 +134,7 @@ rails g llmemory:install
129
134
  rails db:migrate
130
135
  ```
131
136
 
132
- La migración crea las tablas de long-term file-based (resources, items, categories), short-term (checkpoints) y, para graph-based, nodos, aristas y embeddings (`llmemory_nodes`, `llmemory_edges`, `llmemory_embeddings`). Para embeddings se usa pgvector; asegúrate de tener la extensión `vector` en PostgreSQL. Para usar ambas con ActiveRecord:
137
+ La migración crea las tablas de long-term file-based (resources, items, categories), short-term (checkpoints), episódica y procedural (`llmemory_episodes`, `llmemory_skills`; columna `data` JSONB) y, para graph-based, nodos, aristas y embeddings (`llmemory_nodes`, `llmemory_edges`, `llmemory_embeddings`). Para embeddings se usa pgvector; asegúrate de tener la extensión `vector` en PostgreSQL. Para usar ambas con ActiveRecord:
133
138
 
134
139
  ```ruby
135
140
  # config/application.rb o config/initializers/llmemory.rb
@@ -214,7 +219,7 @@ llmemory implements the memory and internal-action concepts from [CoALA — Cogn
214
219
  | Learning action | `memorize` / `record_episode` / `register_skill` / reflection |
215
220
  | Uniform interface | `Llmemory::MemoryModule` (`read`/`write`/`list`/`stats`/`forget`) |
216
221
 
217
- All three long-term memories below are **additive** — episodic and procedural coexist with semantic memory rather than replacing it. Episodic/procedural ship with `:memory` and `:file` backends (SQL/ActiveRecord and vector search are roadmap items); retrieval there is keyword-based.
222
+ All three long-term memories below are **additive** — episodic and procedural coexist with semantic memory rather than replacing it. They support `:memory`, `:file`, `:postgres` and `:active_record` backends. Retrieval is keyword-based by default (tokenized, so multi-word queries work); semantic (embedding) retrieval is **opt-in** via `config.episodic_vector_enabled` / `config.procedural_vector_enabled` (or by injecting a `vector_store:`), which makes `search_candidates` hybrid (vector + keyword).
218
223
 
219
224
  ### Working memory (structured, persists across LLM calls)
220
225
 
@@ -30,6 +30,26 @@ class CreateLlmemoryTables < ActiveRecord::Migration[7.0]
30
30
  end
31
31
  add_index :llmemory_categories, [:user_id, :category_name], unique: true
32
32
 
33
+ # Episodic long-term memory (trajectories) — JSONB document per episode
34
+ create_table :llmemory_episodes, id: false do |t|
35
+ t.string :id, null: false, primary_key: true
36
+ t.string :user_id, null: false
37
+ t.jsonb :data, null: false, default: {}
38
+ t.text :search_text
39
+ t.timestamps
40
+ end
41
+ add_index :llmemory_episodes, :user_id
42
+
43
+ # Procedural long-term memory (skill library) — JSONB document per skill
44
+ create_table :llmemory_skills, id: false do |t|
45
+ t.string :id, null: false, primary_key: true
46
+ t.string :user_id, null: false
47
+ t.jsonb :data, null: false, default: {}
48
+ t.text :search_text
49
+ t.timestamps
50
+ end
51
+ add_index :llmemory_skills, :user_id
52
+
33
53
  create_table :llmemory_checkpoints do |t|
34
54
  t.string :user_id, null: false
35
55
  t.string :session_id, null: false
@@ -70,6 +70,14 @@ module Llmemory
70
70
  Llmemory::LongTerm::GraphBased::Storages::MemoryStorage.new
71
71
  end
72
72
  end
73
+
74
+ def episodic_storage(store_type = nil)
75
+ Llmemory::LongTerm::Episodic::Storages.build(store: (store_type || Llmemory.configuration.long_term_store))
76
+ end
77
+
78
+ def procedural_storage(store_type = nil)
79
+ Llmemory::LongTerm::Procedural::Storages.build(store: (store_type || Llmemory.configuration.long_term_store))
80
+ end
73
81
  end
74
82
  end
75
83
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Llmemory
6
+ module Cli
7
+ module Commands
8
+ class Episodic < Commands::Base
9
+ def option_parser(parser)
10
+ parser.on("--limit N", Integer, "Max number of episodes (newest first)") { |v| @limit = v }
11
+ parser.on("--store TYPE", "Storage type (memory|file|postgres|active_record)") { |v| @store_type = v }
12
+ end
13
+
14
+ def execute(argv, _opts)
15
+ user_id = argv.first
16
+ unless user_id
17
+ $stderr.puts "Usage: llmemory episodes USER_ID [--limit N] [--store TYPE]"
18
+ exit 1
19
+ end
20
+
21
+ storage = episodic_storage(@store_type)
22
+ episodes = storage.list_episodes(user_id, limit: @limit)
23
+
24
+ if episodes.empty?
25
+ puts "No episodes for user #{user_id}."
26
+ return
27
+ end
28
+
29
+ episodes.each do |e|
30
+ id = e[:id] || e["id"]
31
+ summary = e[:summary] || e["summary"]
32
+ outcome = e[:outcome] || e["outcome"]
33
+ importance = e[:importance] || e["importance"]
34
+ steps = e[:steps] || e["steps"] || []
35
+ puts "[#{id}] (importance: #{importance}; outcome: #{outcome || 'n/a'}) #{summary}"
36
+ puts " steps: #{Array(steps).size}"
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Llmemory
6
+ module Cli
7
+ module Commands
8
+ class ForgetLog < Commands::Base
9
+ def execute(argv, _opts)
10
+ user_id = argv.first
11
+ unless user_id
12
+ $stderr.puts "Usage: llmemory forget-log USER_ID"
13
+ exit 1
14
+ end
15
+
16
+ entries = Llmemory::ForgetLog.new(store: short_term_store).entries(user_id)
17
+
18
+ if entries.empty?
19
+ puts "No forget audit entries for user #{user_id}."
20
+ return
21
+ end
22
+
23
+ entries.each do |e|
24
+ type = e[:memory_type] || e["memory_type"]
25
+ count = e[:count] || e["count"]
26
+ reason = e[:reason] || e["reason"]
27
+ at = e[:at] || e["at"]
28
+ ids = e[:ids] || e["ids"] || []
29
+ reason_str = reason ? " — #{reason}" : ""
30
+ puts "[#{at}] #{type}: removed #{count} (#{ids.join(', ')})#{reason_str}"
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Llmemory
6
+ module Cli
7
+ module Commands
8
+ class Procedural < Commands::Base
9
+ def option_parser(parser)
10
+ parser.on("--limit N", Integer, "Max number of skills (newest first)") { |v| @limit = v }
11
+ parser.on("--store TYPE", "Storage type (memory|file|postgres|active_record)") { |v| @store_type = v }
12
+ end
13
+
14
+ def execute(argv, _opts)
15
+ user_id = argv.first
16
+ unless user_id
17
+ $stderr.puts "Usage: llmemory skills USER_ID [--limit N] [--store TYPE]"
18
+ exit 1
19
+ end
20
+
21
+ storage = procedural_storage(@store_type)
22
+ skills = storage.list_skills(user_id, limit: @limit)
23
+
24
+ if skills.empty?
25
+ puts "No skills for user #{user_id}."
26
+ return
27
+ end
28
+
29
+ skills.each do |s|
30
+ id = s[:id] || s["id"]
31
+ name = s[:name] || s["name"]
32
+ kind = s[:kind] || s["kind"]
33
+ version = s[:version] || s["version"]
34
+ succ = (s[:success_count] || s["success_count"] || 0).to_i
35
+ fail = (s[:failure_count] || s["failure_count"] || 0).to_i
36
+ total = succ + fail
37
+ rate = total.zero? ? "n/a" : format("%.2f", succ.to_f / total)
38
+ puts "[#{id}] #{name} v#{version} (#{kind}) — success rate: #{rate} (#{succ}/#{total})"
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Llmemory
6
+ module Cli
7
+ module Commands
8
+ class Working < Commands::Base
9
+ def execute(argv, _opts)
10
+ user_id, session_id = argv
11
+ unless user_id && session_id
12
+ $stderr.puts "Usage: llmemory working USER_ID SESSION_ID"
13
+ exit 1
14
+ end
15
+
16
+ wm = Llmemory::WorkingMemory.new(user_id: user_id, session_id: session_id, store: short_term_store)
17
+ state = wm.to_h
18
+
19
+ if state.empty?
20
+ puts "Empty working memory for user #{user_id}, session #{session_id}."
21
+ return
22
+ end
23
+
24
+ state.each do |slot, value|
25
+ puts "#{slot}: #{value.inspect}"
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
data/lib/llmemory/cli.rb CHANGED
@@ -5,6 +5,10 @@ require_relative "cli/commands/base"
5
5
  require_relative "cli/commands/users"
6
6
  require_relative "cli/commands/short_term"
7
7
  require_relative "cli/commands/long_term"
8
+ require_relative "cli/commands/episodic"
9
+ require_relative "cli/commands/procedural"
10
+ require_relative "cli/commands/working"
11
+ require_relative "cli/commands/forget_log"
8
12
  require_relative "cli/commands/stats"
9
13
  require_relative "cli/commands/search"
10
14
  require_relative "cli/commands/mcp"
@@ -47,6 +51,10 @@ module Llmemory
47
51
  "nodes" => Cli::Commands::LongTerm::Nodes,
48
52
  "edges" => Cli::Commands::LongTerm::Edges,
49
53
  "graph" => Cli::Commands::LongTerm::Graph,
54
+ "episodes" => Cli::Commands::Episodic,
55
+ "skills" => Cli::Commands::Procedural,
56
+ "working" => Cli::Commands::Working,
57
+ "forget_log" => Cli::Commands::ForgetLog,
50
58
  "search" => Cli::Commands::Search,
51
59
  "stats" => Cli::Commands::Stats,
52
60
  "mcp" => Cli::Commands::Mcp
@@ -68,6 +76,10 @@ module Llmemory
68
76
  nodes USER_ID List graph nodes (graph-based)
69
77
  edges USER_ID List graph edges (graph-based)
70
78
  graph USER_ID Export graph (--format dot|json)
79
+ episodes USER_ID List recorded episodes (episodic memory)
80
+ skills USER_ID List registered skills (procedural memory)
81
+ working USER_ID SESSION Show working-memory slots for a session
82
+ forget-log USER_ID Show audit of forgotten entries
71
83
  search USER_ID "query" Search in memory
72
84
  stats [USER_ID] Show statistics
73
85
  mcp [serve] Start MCP server for LLM agents
@@ -11,6 +11,8 @@ module Llmemory
11
11
  :long_term_type,
12
12
  :long_term_store,
13
13
  :long_term_storage_path,
14
+ :episodic_vector_enabled,
15
+ :procedural_vector_enabled,
14
16
  :database_url,
15
17
  :vector_store,
16
18
  :time_decay_half_life_days,
@@ -55,6 +57,8 @@ module Llmemory
55
57
  @long_term_type = :file_based
56
58
  @long_term_store = :memory
57
59
  @long_term_storage_path = ENV["LLMEMORY_STORAGE_PATH"] || "./llmemory_data"
60
+ @episodic_vector_enabled = false
61
+ @procedural_vector_enabled = false
58
62
  @database_url = ENV["DATABASE_URL"]
59
63
  @vector_store = nil
60
64
  @time_decay_half_life_days = 30
@@ -3,6 +3,7 @@
3
3
  require_relative "episode"
4
4
  require_relative "storage"
5
5
  require_relative "../../memory_module"
6
+ require_relative "../../vector_store"
6
7
 
7
8
  module Llmemory
8
9
  module LongTerm
@@ -12,16 +13,19 @@ module Llmemory
12
13
  # memory (file/graph), not replace it, and to feed reflection (P2), which
13
14
  # distills episodes into semantic knowledge.
14
15
  #
15
- # Deliberately LLM-free: recording and retrieval are deterministic. Higher
16
- # order summarization belongs to reflection.
16
+ # Recording/retrieval are deterministic and LLM-free by default. Semantic
17
+ # (embedding) retrieval is opt-in via `config.episodic_vector_enabled` or by
18
+ # injecting a `vector_store:`; when off, search is keyword-only (unchanged).
17
19
  class Memory
18
20
  include Llmemory::MemoryModule
19
21
 
20
22
  attr_reader :user_id, :storage
21
23
 
22
- def initialize(user_id:, storage: nil)
24
+ def initialize(user_id:, storage: nil, vector_store: nil)
23
25
  @user_id = user_id
24
26
  @storage = storage || Storages.build
27
+ @vector_store = vector_store
28
+ @vector_explicit = !vector_store.nil?
25
29
  end
26
30
 
27
31
  # Records a trajectory. `steps` is an array of hashes with any of
@@ -39,7 +43,9 @@ module Llmemory
39
43
  episode.searchable_text, method: "episode_recording", confidence: episode.importance
40
44
  )
41
45
  record = episode.to_h.merge(provenance: provenance)
42
- @storage.save_episode(@user_id, record)
46
+ id = @storage.save_episode(@user_id, record)
47
+ index_vector(id, episode.searchable_text)
48
+ id
43
49
  end
44
50
 
45
51
  def recent_episodes(limit: 10)
@@ -62,22 +68,17 @@ module Llmemory
62
68
  # Retrieval Engine integration. Returns candidates shaped like the other
63
69
  # long-term memories so the Engine can rank episodes by relevance,
64
70
  # recency (temporal decay) and importance (P3), with provenance (P10).
71
+ # Hybrid (vector + keyword) when a vector store is active; otherwise
72
+ # keyword-only.
65
73
  def search_candidates(query, user_id: nil, top_k: 20)
66
74
  uid = user_id || @user_id
67
75
  return [] unless uid == @user_id
68
76
 
69
- @storage.search_episodes(uid, query).first(top_k).map do |e|
70
- episode = Episode.from_h(e)
71
- {
72
- id: episode.id,
73
- text: episode.summary.to_s.empty? ? episode.searchable_text : episode.summary,
74
- timestamp: episode.created_at,
75
- score: 1.0,
76
- importance: episode.importance,
77
- evergreen: false,
78
- provenance: e[:provenance] || e["provenance"]
79
- }
80
- end
77
+ keyword = @storage.search_episodes(uid, query).first(top_k).map { |e| candidate_for(e, 1.0) }
78
+ vs = vector_store
79
+ return keyword unless vs
80
+
81
+ merge_candidates(vector_candidates(query, top_k, vs), keyword, top_k)
81
82
  end
82
83
 
83
84
  # --- MemoryModule uniform interface ---
@@ -105,6 +106,60 @@ module Llmemory
105
106
 
106
107
  private
107
108
 
109
+ # Active vector store: the injected one, or a config-gated lazy build.
110
+ # Returns nil when semantic search is disabled (default).
111
+ def vector_store
112
+ if @vector_explicit
113
+ @vector_store
114
+ elsif Llmemory.configuration.episodic_vector_enabled
115
+ @vector_store ||= Llmemory::VectorStore.build(source_type: "episode")
116
+ end
117
+ end
118
+
119
+ # Best-effort embedding indexing; a failure must never break recording.
120
+ def index_vector(id, text)
121
+ vs = vector_store
122
+ return if vs.nil? || text.to_s.strip.empty?
123
+ embedding = vs.embed(text)
124
+ return unless embedding
125
+ vs.store(id: id, embedding: embedding, metadata: { text: text, created_at: Time.now }, user_id: @user_id)
126
+ rescue StandardError
127
+ nil
128
+ end
129
+
130
+ def vector_candidates(query, top_k, vs)
131
+ vs.search_by_text(query.to_s, top_k: top_k, user_id: @user_id).filter_map do |r|
132
+ raw = @storage.get_episode(@user_id, r[:id] || r["id"])
133
+ raw && candidate_for(raw, (r[:score] || r["score"] || 1.0).to_f)
134
+ end
135
+ rescue StandardError
136
+ []
137
+ end
138
+
139
+ def candidate_for(raw, score)
140
+ episode = Episode.from_h(raw)
141
+ {
142
+ id: episode.id,
143
+ text: episode.summary.to_s.empty? ? episode.searchable_text : episode.summary,
144
+ timestamp: episode.created_at,
145
+ score: score,
146
+ importance: episode.importance,
147
+ evergreen: false,
148
+ provenance: raw[:provenance] || raw["provenance"]
149
+ }
150
+ end
151
+
152
+ # Dedup by id keeping the higher score; highest score first, capped.
153
+ def merge_candidates(primary, secondary, top_k)
154
+ by_id = {}
155
+ (primary + secondary).each do |c|
156
+ key = c[:id] || c[:text]
157
+ existing = by_id[key]
158
+ by_id[key] = c if existing.nil? || c[:score].to_f > existing[:score].to_f
159
+ end
160
+ by_id.values.sort_by { |c| -c[:score].to_f }.first(top_k)
161
+ end
162
+
108
163
  # Cheap, deterministic summary when the caller does not provide one.
109
164
  # LLM-based summarization is reflection's job (P2).
110
165
  def derive_summary(steps)
@@ -3,6 +3,7 @@
3
3
  require_relative "storages/base"
4
4
  require_relative "storages/memory_storage"
5
5
  require_relative "storages/file_storage"
6
+ require_relative "storages/database_storage"
6
7
 
7
8
  module Llmemory
8
9
  module LongTerm
@@ -11,16 +12,17 @@ module Llmemory
11
12
  Storage = Storages::MemoryStorage
12
13
 
13
14
  module Storages
14
- def self.build(store: nil, base_path: nil)
15
+ def self.build(store: nil, base_path: nil, database_url: nil)
15
16
  case (store || Llmemory.configuration.long_term_store).to_s.to_sym
16
17
  when :memory
17
18
  MemoryStorage.new
18
19
  when :file
19
20
  FileStorage.new(base_path: base_path || Llmemory.configuration.long_term_storage_path)
20
- when :postgres, :database, :active_record, :activerecord
21
- raise NotImplementedError,
22
- "Episodic SQL/ActiveRecord storage is not implemented yet; use :memory or :file " \
23
- "(or pass an explicit storage instance)."
21
+ when :postgres, :database
22
+ DatabaseStorage.new(database_url: database_url || Llmemory.configuration.database_url)
23
+ when :active_record, :activerecord
24
+ require_relative "storages/active_record_storage"
25
+ ActiveRecordStorage.new
24
26
  else
25
27
  MemoryStorage.new
26
28
  end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Model for Episodic ActiveRecordStorage. Loaded only when using
4
+ # store: :active_record. JSONB `data` auto-deserializes to a Hash in Rails 5+.
5
+
6
+ module Llmemory
7
+ module LongTerm
8
+ module Episodic
9
+ module Storages
10
+ class LlmemoryEpisode < ::ActiveRecord::Base
11
+ self.table_name = "llmemory_episodes"
12
+ self.primary_key = "id"
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "time"
6
+ require_relative "base"
7
+
8
+ module Llmemory
9
+ module LongTerm
10
+ module Episodic
11
+ module Storages
12
+ # ActiveRecord backend. Stores each episode as a JSONB `data` document;
13
+ # AR auto-deserializes jsonb to a Hash (string keys), which Episode.from_h
14
+ # handles. Mirrors the file-based ActiveRecordStorage pattern.
15
+ class ActiveRecordStorage < Base
16
+ def initialize
17
+ self.class.load_models!
18
+ end
19
+
20
+ def self.load_models!
21
+ return if @models_loaded
22
+ require "active_record"
23
+ require_relative "active_record_models"
24
+ @models_loaded = true
25
+ end
26
+
27
+ def save_episode(user_id, episode)
28
+ id = episode[:id] || episode["id"] || "ep_#{SecureRandom.hex(8)}"
29
+ data = stringify(episode).merge("id" => id, "user_id" => user_id)
30
+ data["created_at"] ||= Time.now.utc.iso8601
31
+ rec = LlmemoryEpisode.find_or_initialize_by(id: id)
32
+ rec.user_id = user_id
33
+ rec.data = data
34
+ rec.search_text = searchable_text(data)
35
+ rec.created_at ||= Time.current
36
+ rec.save!
37
+ id
38
+ end
39
+
40
+ def get_episode(user_id, id)
41
+ rec = LlmemoryEpisode.find_by(user_id: user_id, id: id)
42
+ rec&.data
43
+ end
44
+
45
+ def list_episodes(user_id, limit: nil)
46
+ scope = LlmemoryEpisode.where(user_id: user_id).order(created_at: :desc)
47
+ scope = scope.limit(limit) if limit && limit.to_i.positive?
48
+ scope.map(&:data)
49
+ end
50
+
51
+ def search_episodes(user_id, query)
52
+ token_scope(LlmemoryEpisode.where(user_id: user_id), "search_text", query)
53
+ .order(created_at: :desc).map(&:data)
54
+ end
55
+
56
+ def count_episodes(user_id)
57
+ LlmemoryEpisode.where(user_id: user_id).count
58
+ end
59
+
60
+ def delete_episodes(user_id, ids)
61
+ LlmemoryEpisode.where(user_id: user_id, id: Array(ids).map(&:to_s)).delete_all
62
+ end
63
+
64
+ def list_users
65
+ LlmemoryEpisode.distinct.pluck(:user_id)
66
+ end
67
+
68
+ private
69
+
70
+ def token_scope(scope, column, query)
71
+ tokens = Llmemory::Tokenizer.tokenize(query)
72
+ return scope if tokens.empty?
73
+ clause = tokens.map { "LOWER(#{column}) LIKE LOWER(?)" }.join(" OR ")
74
+ scope.where(clause, *tokens.map { |t| "%#{t}%" })
75
+ end
76
+
77
+ def stringify(hash)
78
+ JSON.parse(JSON.generate(hash))
79
+ end
80
+
81
+ def searchable_text(data)
82
+ parts = [data["summary"], data["outcome"]]
83
+ Array(data["steps"]).each do |s|
84
+ next unless s.is_a?(Hash)
85
+ parts << s["observation"] << s["action"] << s["result"]
86
+ end
87
+ parts.compact.join("\n")
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end