llmemory 0.1.17 → 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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +178 -1
  3. data/lib/generators/llmemory/install/templates/create_llmemory_tables.rb +20 -0
  4. data/lib/llmemory/actions/reason.rb +49 -0
  5. data/lib/llmemory/actions.rb +8 -0
  6. data/lib/llmemory/cli/commands/base.rb +8 -0
  7. data/lib/llmemory/cli/commands/episodic.rb +42 -0
  8. data/lib/llmemory/cli/commands/forget_log.rb +36 -0
  9. data/lib/llmemory/cli/commands/procedural.rb +44 -0
  10. data/lib/llmemory/cli/commands/working.rb +31 -0
  11. data/lib/llmemory/cli.rb +12 -0
  12. data/lib/llmemory/configuration.rb +6 -0
  13. data/lib/llmemory/forget_log.rb +50 -0
  14. data/lib/llmemory/long_term/episodic/memory.rb +97 -15
  15. data/lib/llmemory/long_term/episodic/storage.rb +7 -5
  16. data/lib/llmemory/long_term/episodic/storages/active_record_models.rb +17 -0
  17. data/lib/llmemory/long_term/episodic/storages/active_record_storage.rb +93 -0
  18. data/lib/llmemory/long_term/episodic/storages/base.rb +5 -0
  19. data/lib/llmemory/long_term/episodic/storages/database_storage.rb +135 -0
  20. data/lib/llmemory/long_term/episodic/storages/file_storage.rb +11 -3
  21. data/lib/llmemory/long_term/episodic/storages/memory_storage.rb +9 -3
  22. data/lib/llmemory/long_term/file_based/memory.rb +31 -0
  23. data/lib/llmemory/long_term/file_based/storages/active_record_storage.rb +11 -4
  24. data/lib/llmemory/long_term/file_based/storages/database_storage.rb +16 -6
  25. data/lib/llmemory/long_term/file_based/storages/file_storage.rb +2 -4
  26. data/lib/llmemory/long_term/file_based/storages/memory_storage.rb +2 -4
  27. data/lib/llmemory/long_term/graph_based/memory.rb +95 -51
  28. data/lib/llmemory/long_term/procedural/memory.rb +170 -0
  29. data/lib/llmemory/long_term/procedural/skill.rb +93 -0
  30. data/lib/llmemory/long_term/procedural/storage.rb +33 -0
  31. data/lib/llmemory/long_term/procedural/storages/active_record_models.rb +17 -0
  32. data/lib/llmemory/long_term/procedural/storages/active_record_storage.rb +104 -0
  33. data/lib/llmemory/long_term/procedural/storages/base.rb +53 -0
  34. data/lib/llmemory/long_term/procedural/storages/database_storage.rb +148 -0
  35. data/lib/llmemory/long_term/procedural/storages/file_storage.rb +135 -0
  36. data/lib/llmemory/long_term/procedural/storages/memory_storage.rb +79 -0
  37. data/lib/llmemory/long_term/procedural.rb +12 -0
  38. data/lib/llmemory/long_term.rb +2 -0
  39. data/lib/llmemory/mcp/server.rb +13 -1
  40. data/lib/llmemory/mcp/tools/memory_episode_record.rb +48 -0
  41. data/lib/llmemory/mcp/tools/memory_episodes.rb +43 -0
  42. data/lib/llmemory/mcp/tools/memory_forget.rb +53 -0
  43. data/lib/llmemory/mcp/tools/memory_retrieve.rb +10 -2
  44. data/lib/llmemory/mcp/tools/memory_skill_register.rb +35 -0
  45. data/lib/llmemory/mcp/tools/memory_skill_report.rb +35 -0
  46. data/lib/llmemory/mcp/tools/memory_skills.rb +43 -0
  47. data/lib/llmemory/memory.rb +34 -1
  48. data/lib/llmemory/memory_module.rb +55 -0
  49. data/lib/llmemory/retrieval/bm25_scorer.rb +1 -1
  50. data/lib/llmemory/retrieval/engine.rb +115 -6
  51. data/lib/llmemory/retrieval/feedback_store.rb +50 -0
  52. data/lib/llmemory/retrieval/mmr_reranker.rb +1 -1
  53. data/lib/llmemory/short_term/checkpoint.rb +2 -14
  54. data/lib/llmemory/short_term/session_lifecycle.rb +22 -13
  55. data/lib/llmemory/short_term/stores.rb +27 -0
  56. data/lib/llmemory/tokenizer.rb +27 -0
  57. data/lib/llmemory/vector_store/active_record_store.rb +4 -3
  58. data/lib/llmemory/vector_store.rb +14 -0
  59. data/lib/llmemory/version.rb +1 -1
  60. data/lib/llmemory/working_memory.rb +83 -0
  61. data/lib/llmemory.rb +5 -0
  62. metadata +32 -1
@@ -2,6 +2,8 @@
2
2
 
3
3
  require_relative "episode"
4
4
  require_relative "storage"
5
+ require_relative "../../memory_module"
6
+ require_relative "../../vector_store"
5
7
 
6
8
  module Llmemory
7
9
  module LongTerm
@@ -11,14 +13,19 @@ module Llmemory
11
13
  # memory (file/graph), not replace it, and to feed reflection (P2), which
12
14
  # distills episodes into semantic knowledge.
13
15
  #
14
- # Deliberately LLM-free: recording and retrieval are deterministic. Higher
15
- # 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).
16
19
  class Memory
20
+ include Llmemory::MemoryModule
21
+
17
22
  attr_reader :user_id, :storage
18
23
 
19
- def initialize(user_id:, storage: nil)
24
+ def initialize(user_id:, storage: nil, vector_store: nil)
20
25
  @user_id = user_id
21
26
  @storage = storage || Storages.build
27
+ @vector_store = vector_store
28
+ @vector_explicit = !vector_store.nil?
22
29
  end
23
30
 
24
31
  # Records a trajectory. `steps` is an array of hashes with any of
@@ -36,7 +43,9 @@ module Llmemory
36
43
  episode.searchable_text, method: "episode_recording", confidence: episode.importance
37
44
  )
38
45
  record = episode.to_h.merge(provenance: provenance)
39
- @storage.save_episode(@user_id, record)
46
+ id = @storage.save_episode(@user_id, record)
47
+ index_vector(id, episode.searchable_text)
48
+ id
40
49
  end
41
50
 
42
51
  def recent_episodes(limit: 10)
@@ -59,25 +68,98 @@ module Llmemory
59
68
  # Retrieval Engine integration. Returns candidates shaped like the other
60
69
  # long-term memories so the Engine can rank episodes by relevance,
61
70
  # recency (temporal decay) and importance (P3), with provenance (P10).
71
+ # Hybrid (vector + keyword) when a vector store is active; otherwise
72
+ # keyword-only.
62
73
  def search_candidates(query, user_id: nil, top_k: 20)
63
74
  uid = user_id || @user_id
64
75
  return [] unless uid == @user_id
65
76
 
66
- @storage.search_episodes(uid, query).first(top_k).map do |e|
67
- episode = Episode.from_h(e)
68
- {
69
- text: episode.summary.to_s.empty? ? episode.searchable_text : episode.summary,
70
- timestamp: episode.created_at,
71
- score: 1.0,
72
- importance: episode.importance,
73
- evergreen: false,
74
- provenance: e[:provenance] || e["provenance"]
75
- }
76
- 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)
82
+ end
83
+
84
+ # --- MemoryModule uniform interface ---
85
+
86
+ def write(steps:, summary: nil, outcome: nil, importance: 0.5, **_meta)
87
+ record_episode(steps: steps, summary: summary, outcome: outcome, importance: importance)
88
+ end
89
+
90
+ def list(user_id: nil, limit: nil)
91
+ episodes(limit: limit)
92
+ end
93
+
94
+ def stats(user_id: nil)
95
+ { episodes: count }
96
+ end
97
+
98
+ def forget(ids:, reason: nil)
99
+ requested = Array(ids).map(&:to_s)
100
+ existing = @storage.list_episodes(@user_id).map { |e| (e[:id] || e["id"]).to_s }
101
+ removed = requested & existing
102
+ @storage.delete_episodes(@user_id, removed)
103
+ forget_log.record(@user_id, memory_type: "episodic", ids: removed, reason: reason)
104
+ removed.size
77
105
  end
78
106
 
79
107
  private
80
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
+
81
163
  # Cheap, deterministic summary when the caller does not provide one.
82
164
  # LLM-based summarization is reflection's job (P2).
83
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
@@ -29,6 +29,11 @@ module Llmemory
29
29
  raise NotImplementedError, "#{self.class}#count_episodes must be implemented"
30
30
  end
31
31
 
32
+ # Deletes episodes by id. Returns the number actually removed.
33
+ def delete_episodes(user_id, ids)
34
+ raise NotImplementedError, "#{self.class}#delete_episodes must be implemented"
35
+ end
36
+
32
37
  def list_users
33
38
  raise NotImplementedError, "#{self.class}#list_users must be implemented"
34
39
  end
@@ -0,0 +1,135 @@
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
+ # PostgreSQL backend. Each episode is stored as a JSONB `data` document
13
+ # (plus id/user_id/created_at and a denormalized search_text for keyword
14
+ # search), mirroring the file-based DatabaseStorage pattern.
15
+ class DatabaseStorage < Base
16
+ def initialize(database_url: nil)
17
+ @database_url = database_url || Llmemory.configuration.database_url
18
+ @connection = nil
19
+ end
20
+
21
+ def save_episode(user_id, episode)
22
+ ensure_tables!
23
+ id = episode[:id] || episode["id"] || "ep_#{SecureRandom.hex(8)}"
24
+ data = symbolize(episode).merge(id: id, user_id: user_id)
25
+ data[:created_at] ||= Time.now.utc.iso8601
26
+ conn.exec_params(
27
+ "INSERT INTO llmemory_episodes (id, user_id, data, search_text, created_at) " \
28
+ "VALUES ($1, $2, $3::jsonb, $4, $5) " \
29
+ "ON CONFLICT (id) DO UPDATE SET data = $3::jsonb, search_text = $4",
30
+ [id, user_id, JSON.generate(data), searchable_text(data), created_at_value(data)]
31
+ )
32
+ id
33
+ end
34
+
35
+ def get_episode(user_id, id)
36
+ ensure_tables!
37
+ rows = conn.exec_params("SELECT data FROM llmemory_episodes WHERE user_id = $1 AND id = $2", [user_id, id])
38
+ rows.any? ? parse_data(rows.first["data"]) : nil
39
+ end
40
+
41
+ def list_episodes(user_id, limit: nil)
42
+ ensure_tables!
43
+ sql = "SELECT data FROM llmemory_episodes WHERE user_id = $1 ORDER BY created_at DESC"
44
+ sql += " LIMIT #{limit.to_i}" if limit && limit.to_i.positive?
45
+ conn.exec_params(sql, [user_id]).map { |r| parse_data(r["data"]) }
46
+ end
47
+
48
+ def search_episodes(user_id, query)
49
+ ensure_tables!
50
+ suffix, params = token_filter("search_text", query, 2)
51
+ conn.exec_params(
52
+ "SELECT data FROM llmemory_episodes WHERE user_id = $1#{suffix} ORDER BY created_at DESC",
53
+ [user_id, *params]
54
+ ).map { |r| parse_data(r["data"]) }
55
+ end
56
+
57
+ def count_episodes(user_id)
58
+ ensure_tables!
59
+ conn.exec_params("SELECT COUNT(*) AS c FROM llmemory_episodes WHERE user_id = $1", [user_id]).first["c"].to_i
60
+ end
61
+
62
+ def delete_episodes(user_id, ids)
63
+ ensure_tables!
64
+ Array(ids).sum do |id|
65
+ conn.exec_params("DELETE FROM llmemory_episodes WHERE user_id = $1 AND id = $2", [user_id, id]).cmd_tuples
66
+ end
67
+ end
68
+
69
+ def list_users
70
+ ensure_tables!
71
+ conn.exec("SELECT DISTINCT user_id FROM llmemory_episodes").map { |r| r["user_id"] }
72
+ end
73
+
74
+ private
75
+
76
+ def conn
77
+ @connection ||= begin
78
+ require "pg"
79
+ PG.connect(@database_url)
80
+ end
81
+ end
82
+
83
+ def ensure_tables!
84
+ conn.exec(<<~SQL)
85
+ CREATE TABLE IF NOT EXISTS llmemory_episodes (
86
+ id TEXT NOT NULL PRIMARY KEY,
87
+ user_id TEXT NOT NULL,
88
+ data JSONB NOT NULL DEFAULT '{}'::jsonb,
89
+ search_text TEXT,
90
+ created_at TIMESTAMPTZ NOT NULL
91
+ );
92
+ CREATE INDEX IF NOT EXISTS idx_llmemory_episodes_user_id ON llmemory_episodes(user_id);
93
+ SQL
94
+ end
95
+
96
+ # OR-of-token LIKE filter (see file-based DatabaseStorage). [""] for an
97
+ # empty query => match all.
98
+ def token_filter(column, query, start_index)
99
+ tokens = Llmemory::Tokenizer.tokenize(query)
100
+ return ["", []] if tokens.empty?
101
+ likes = tokens.each_index.map { |i| "LOWER(#{column}) LIKE $#{start_index + i}" }
102
+ [" AND (#{likes.join(' OR ')})", tokens.map { |t| "%#{t}%" }]
103
+ end
104
+
105
+ def parse_data(value)
106
+ JSON.parse(value.to_s, symbolize_names: true)
107
+ rescue JSON::ParserError
108
+ {}
109
+ end
110
+
111
+ def symbolize(hash)
112
+ hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
113
+ end
114
+
115
+ def searchable_text(data)
116
+ parts = [data[:summary], data[:outcome]]
117
+ Array(data[:steps]).each do |s|
118
+ next unless s.is_a?(Hash)
119
+ parts << (s[:observation] || s["observation"])
120
+ parts << (s[:action] || s["action"])
121
+ parts << (s[:result] || s["result"])
122
+ end
123
+ parts.compact.join("\n")
124
+ end
125
+
126
+ def created_at_value(data)
127
+ ca = data[:created_at]
128
+ return Time.now.utc.iso8601 if ca.nil?
129
+ ca.respond_to?(:iso8601) ? ca.iso8601 : ca.to_s
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -35,9 +35,8 @@ module Llmemory
35
35
  end
36
36
 
37
37
  def search_episodes(user_id, query)
38
- q = query.to_s.downcase
39
- return list_episodes(user_id) if q.strip.empty?
40
- all_episodes(user_id).select { |e| episode_text(e).downcase.include?(q) }
38
+ return list_episodes(user_id) if query.to_s.strip.empty?
39
+ all_episodes(user_id).select { |e| Llmemory::Tokenizer.matches?(episode_text(e), query) }
41
40
  end
42
41
 
43
42
  def count_episodes(user_id)
@@ -46,6 +45,15 @@ module Llmemory
46
45
  Dir.children(dir).count { |f| f.end_with?(".json") }
47
46
  end
48
47
 
48
+ def delete_episodes(user_id, ids)
49
+ Array(ids).map(&:to_s).count do |id|
50
+ path = episode_path(user_id, id)
51
+ next false unless File.file?(path)
52
+ File.delete(path)
53
+ true
54
+ end
55
+ end
56
+
49
57
  def list_users
50
58
  return [] unless Dir.exist?(@base_path)
51
59
  Dir.children(@base_path).select { |d| Dir.exist?(File.join(@base_path, d, "episodes")) }
@@ -31,15 +31,21 @@ module Llmemory
31
31
  end
32
32
 
33
33
  def search_episodes(user_id, query)
34
- q = query.to_s.downcase
35
- return list_episodes(user_id) if q.strip.empty?
36
- @episodes[user_id].select { |e| episode_text(e).downcase.include?(q) }
34
+ return list_episodes(user_id) if query.to_s.strip.empty?
35
+ @episodes[user_id].select { |e| Llmemory::Tokenizer.matches?(episode_text(e), query) }
37
36
  end
38
37
 
39
38
  def count_episodes(user_id)
40
39
  @episodes[user_id].size
41
40
  end
42
41
 
42
+ def delete_episodes(user_id, ids)
43
+ ids = Array(ids).map(&:to_s)
44
+ before = @episodes[user_id].size
45
+ @episodes[user_id].reject! { |e| ids.include?(e[:id].to_s) }
46
+ before - @episodes[user_id].size
47
+ end
48
+
43
49
  def list_users
44
50
  @episodes.keys
45
51
  end
@@ -5,11 +5,14 @@ require_relative "item"
5
5
  require_relative "category"
6
6
  require_relative "storage"
7
7
  require_relative "../../noise_filter"
8
+ require_relative "../../memory_module"
8
9
 
9
10
  module Llmemory
10
11
  module LongTerm
11
12
  module FileBased
12
13
  class Memory
14
+ include Llmemory::MemoryModule
15
+
13
16
  def initialize(user_id:, storage: nil, llm: nil, extractor: nil)
14
17
  @user_id = user_id
15
18
  @storage = storage || Storages.build
@@ -63,6 +66,7 @@ module Llmemory
63
66
 
64
67
  items.first(top_k).each do |i|
65
68
  out << {
69
+ id: i[:id] || i["id"],
66
70
  text: i[:content] || i["content"],
67
71
  timestamp: i[:created_at] || i["created_at"],
68
72
  score: 1.0,
@@ -72,6 +76,7 @@ module Llmemory
72
76
  end
73
77
  resources.first([top_k - out.size, 0].max).each do |r|
74
78
  out << {
79
+ id: r[:id] || r["id"],
75
80
  text: r[:text] || r["text"],
76
81
  timestamp: r[:created_at] || r["created_at"],
77
82
  score: 0.9
@@ -100,6 +105,32 @@ module Llmemory
100
105
  )
101
106
  end
102
107
 
108
+ # --- MemoryModule uniform interface ---
109
+
110
+ def write(payload, **_meta)
111
+ memorize(payload)
112
+ end
113
+
114
+ def list(user_id: nil, limit: nil)
115
+ @storage.list_items(user_id: user_id || @user_id, limit: limit)
116
+ end
117
+
118
+ def stats(user_id: nil)
119
+ { items: @storage.count_items(user_id: user_id || @user_id) }
120
+ end
121
+
122
+ # Removes items/resources by id and records the removal in the audit log.
123
+ def forget(ids:, reason: nil)
124
+ requested = Array(ids).map(&:to_s)
125
+ existing = (@storage.get_all_items(@user_id) + @storage.get_all_resources(@user_id))
126
+ .map { |r| (r[:id] || r["id"]).to_s }
127
+ removed = requested & existing
128
+ @storage.archive_items(@user_id, removed)
129
+ @storage.archive_resources(@user_id, removed)
130
+ forget_log.record(@user_id, memory_type: "file_based", ids: removed, reason: reason)
131
+ removed.size
132
+ end
133
+
103
134
  attr_reader :storage, :user_id
104
135
 
105
136
  private
@@ -64,13 +64,11 @@ module Llmemory
64
64
  end
65
65
 
66
66
  def search_items(user_id, query)
67
- q = "%#{sanitize_like(query)}%"
68
- LlmemoryItem.where(user_id: user_id).where("LOWER(content) LIKE LOWER(?)", q).map { |r| row_to_item(r) }
67
+ token_scope(LlmemoryItem.where(user_id: user_id), "content", query).map { |r| row_to_item(r) }
69
68
  end
70
69
 
71
70
  def search_resources(user_id, query)
72
- q = "%#{sanitize_like(query)}%"
73
- LlmemoryResource.where(user_id: user_id).where("LOWER(text) LIKE LOWER(?)", q).map { |r| row_to_resource(r) }
71
+ token_scope(LlmemoryResource.where(user_id: user_id), "text", query).map { |r| row_to_resource(r) }
74
72
  end
75
73
 
76
74
  def get_resources_since(user_id, hours:)
@@ -181,6 +179,15 @@ module Llmemory
181
179
  (str || "").to_s.gsub(/[%_\\]/) { |c| "\\#{c}" }
182
180
  end
183
181
 
182
+ # OR-of-token LIKE scope for keyword search; unchanged scope (match all)
183
+ # when the query has no tokens.
184
+ def token_scope(scope, column, query)
185
+ tokens = Llmemory::Tokenizer.tokenize(query)
186
+ return scope if tokens.empty?
187
+ clause = tokens.map { "LOWER(#{column}) LIKE LOWER(?)" }.join(" OR ")
188
+ scope.where(clause, *tokens.map { |t| "%#{sanitize_like(t)}%" })
189
+ end
190
+
184
191
  def row_to_item(r)
185
192
  h = {
186
193
  id: r.id,
@@ -65,20 +65,20 @@ module Llmemory
65
65
 
66
66
  def search_items(user_id, query)
67
67
  ensure_tables!
68
- pattern = "%#{conn.escape_string(query.to_s.downcase)}%"
68
+ suffix, params = token_filter("content", query, 2)
69
69
  rows = conn.exec_params(
70
- "SELECT id, category, content, source_resource_id, importance, provenance, created_at FROM llmemory_items WHERE user_id = $1 AND LOWER(content) LIKE $2",
71
- [user_id, pattern]
70
+ "SELECT id, category, content, source_resource_id, importance, provenance, created_at FROM llmemory_items WHERE user_id = $1#{suffix}",
71
+ [user_id, *params]
72
72
  )
73
73
  rows_to_items(rows)
74
74
  end
75
75
 
76
76
  def search_resources(user_id, query)
77
77
  ensure_tables!
78
- pattern = "%#{conn.escape_string(query.to_s.downcase)}%"
78
+ suffix, params = token_filter("text", query, 2)
79
79
  rows = conn.exec_params(
80
- "SELECT id, text, created_at FROM llmemory_resources WHERE user_id = $1 AND LOWER(text) LIKE $2",
81
- [user_id, pattern]
80
+ "SELECT id, text, created_at FROM llmemory_resources WHERE user_id = $1#{suffix}",
81
+ [user_id, *params]
82
82
  )
83
83
  rows_to_resources(rows)
84
84
  end
@@ -290,6 +290,16 @@ module Llmemory
290
290
  end
291
291
  end
292
292
 
293
+ # Builds an OR-of-token LIKE filter for keyword search. Returns
294
+ # ["" , []] for an empty query (match all). Tokens are [a-z0-9]{2,} so
295
+ # they carry no LIKE wildcards.
296
+ def token_filter(column, query, start_index)
297
+ tokens = Llmemory::Tokenizer.tokenize(query)
298
+ return ["", []] if tokens.empty?
299
+ likes = tokens.each_index.map { |i| "LOWER(#{column}) LIKE $#{start_index + i}" }
300
+ [" AND (#{likes.join(' OR ')})", tokens.map { |t| "%#{t}%" }]
301
+ end
302
+
293
303
  def parse_provenance(value)
294
304
  return nil if value.nil? || value.to_s.strip.empty?
295
305
  JSON.parse(value, symbolize_names: true)
@@ -62,13 +62,11 @@ module Llmemory
62
62
  end
63
63
 
64
64
  def search_items(user_id, query)
65
- query_lower = query.downcase
66
- get_all_items(user_id).select { |i| (i[:content] || i["content"]).to_s.downcase.include?(query_lower) }
65
+ get_all_items(user_id).select { |i| Llmemory::Tokenizer.matches?(i[:content] || i["content"], query) }
67
66
  end
68
67
 
69
68
  def search_resources(user_id, query)
70
- query_lower = query.downcase
71
- get_all_resources(user_id).select { |r| (r[:text] || r["text"]).to_s.downcase.include?(query_lower) }
69
+ get_all_resources(user_id).select { |r| Llmemory::Tokenizer.matches?(r[:text] || r["text"], query) }
72
70
  end
73
71
 
74
72
  def get_resources_since(user_id, hours:)
@@ -51,13 +51,11 @@ module Llmemory
51
51
  end
52
52
 
53
53
  def search_items(user_id, query)
54
- query_lower = query.downcase
55
- @items[user_id].select { |i| i[:content].to_s.downcase.include?(query_lower) }
54
+ @items[user_id].select { |i| Llmemory::Tokenizer.matches?(i[:content], query) }
56
55
  end
57
56
 
58
57
  def search_resources(user_id, query)
59
- query_lower = query.downcase
60
- @resources[user_id].select { |r| r[:text].to_s.downcase.include?(query_lower) }
58
+ @resources[user_id].select { |r| Llmemory::Tokenizer.matches?(r[:text], query) }
61
59
  end
62
60
 
63
61
  def get_resources_since(user_id, hours:)