llmemory 0.2.0 → 0.2.2

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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +54 -3
  3. data/app/controllers/llmemory/dashboard/application_controller.rb +15 -1
  4. data/app/controllers/llmemory/dashboard/episodic_controller.rb +22 -0
  5. data/app/controllers/llmemory/dashboard/forget_log_controller.rb +12 -0
  6. data/app/controllers/llmemory/dashboard/maintenance_controller.rb +92 -0
  7. data/app/controllers/llmemory/dashboard/procedural_controller.rb +22 -0
  8. data/app/controllers/llmemory/dashboard/reflection_controller.rb +37 -0
  9. data/app/controllers/llmemory/dashboard/working_controller.rb +14 -0
  10. data/app/views/llmemory/dashboard/episodic/index.html.erb +37 -0
  11. data/app/views/llmemory/dashboard/forget_log/show.html.erb +23 -0
  12. data/app/views/llmemory/dashboard/maintenance/show.html.erb +65 -0
  13. data/app/views/llmemory/dashboard/procedural/index.html.erb +38 -0
  14. data/app/views/llmemory/dashboard/reflection/show.html.erb +29 -0
  15. data/app/views/llmemory/dashboard/users/show.html.erb +16 -0
  16. data/app/views/llmemory/dashboard/working/show.html.erb +20 -0
  17. data/config/routes.rb +14 -0
  18. data/lib/generators/llmemory/install/templates/create_llmemory_tables.rb +22 -0
  19. data/lib/llmemory/cli/commands/base.rb +8 -0
  20. data/lib/llmemory/cli/commands/episodic.rb +42 -0
  21. data/lib/llmemory/cli/commands/forget_log.rb +36 -0
  22. data/lib/llmemory/cli/commands/maintain.rb +62 -0
  23. data/lib/llmemory/cli/commands/mine_skills.rb +50 -0
  24. data/lib/llmemory/cli/commands/procedural.rb +44 -0
  25. data/lib/llmemory/cli/commands/working.rb +31 -0
  26. data/lib/llmemory/cli.rb +18 -0
  27. data/lib/llmemory/configuration.rb +11 -1
  28. data/lib/llmemory/instrumentation.rb +33 -0
  29. data/lib/llmemory/llm/anthropic.rb +19 -15
  30. data/lib/llmemory/llm/openai.rb +16 -12
  31. data/lib/llmemory/long_term/episodic/memory.rb +94 -26
  32. data/lib/llmemory/long_term/episodic/storage.rb +7 -5
  33. data/lib/llmemory/long_term/episodic/storages/active_record_models.rb +17 -0
  34. data/lib/llmemory/long_term/episodic/storages/active_record_storage.rb +103 -0
  35. data/lib/llmemory/long_term/episodic/storages/base.rb +15 -2
  36. data/lib/llmemory/long_term/episodic/storages/database_storage.rb +156 -0
  37. data/lib/llmemory/long_term/episodic/storages/file_storage.rb +28 -8
  38. data/lib/llmemory/long_term/episodic/storages/memory_storage.rb +36 -6
  39. data/lib/llmemory/long_term/file_based/memory.rb +12 -4
  40. data/lib/llmemory/long_term/file_based/storages/active_record_storage.rb +15 -6
  41. data/lib/llmemory/long_term/file_based/storages/base.rb +2 -2
  42. data/lib/llmemory/long_term/file_based/storages/database_storage.rb +20 -8
  43. data/lib/llmemory/long_term/file_based/storages/file_storage.rb +6 -6
  44. data/lib/llmemory/long_term/file_based/storages/memory_storage.rb +6 -6
  45. data/lib/llmemory/long_term/graph_based/memory.rb +89 -64
  46. data/lib/llmemory/long_term/graph_based/storages/active_record_storage.rb +4 -2
  47. data/lib/llmemory/long_term/graph_based/storages/base.rb +2 -2
  48. data/lib/llmemory/long_term/graph_based/storages/memory_storage.rb +4 -2
  49. data/lib/llmemory/long_term/procedural/memory.rb +97 -30
  50. data/lib/llmemory/long_term/procedural/skill.rb +6 -2
  51. data/lib/llmemory/long_term/procedural/storage.rb +7 -5
  52. data/lib/llmemory/long_term/procedural/storages/active_record_models.rb +17 -0
  53. data/lib/llmemory/long_term/procedural/storages/active_record_storage.rb +114 -0
  54. data/lib/llmemory/long_term/procedural/storages/base.rb +14 -1
  55. data/lib/llmemory/long_term/procedural/storages/database_storage.rb +169 -0
  56. data/lib/llmemory/long_term/procedural/storages/file_storage.rb +29 -9
  57. data/lib/llmemory/long_term/procedural/storages/memory_storage.rb +37 -7
  58. data/lib/llmemory/maintenance/cognitive_pass.rb +109 -0
  59. data/lib/llmemory/maintenance/ttl_expiry.rb +50 -0
  60. data/lib/llmemory/maintenance.rb +2 -0
  61. data/lib/llmemory/mcp/server.rb +17 -1
  62. data/lib/llmemory/mcp/tools/memory_episode_record.rb +48 -0
  63. data/lib/llmemory/mcp/tools/memory_episodes.rb +43 -0
  64. data/lib/llmemory/mcp/tools/memory_forget.rb +53 -0
  65. data/lib/llmemory/mcp/tools/memory_maintain.rb +53 -0
  66. data/lib/llmemory/mcp/tools/memory_mine_skills.rb +53 -0
  67. data/lib/llmemory/mcp/tools/memory_retrieve.rb +10 -2
  68. data/lib/llmemory/mcp/tools/memory_skill_register.rb +35 -0
  69. data/lib/llmemory/mcp/tools/memory_skill_report.rb +35 -0
  70. data/lib/llmemory/mcp/tools/memory_skills.rb +43 -0
  71. data/lib/llmemory/memory.rb +48 -3
  72. data/lib/llmemory/memory_module.rb +13 -6
  73. data/lib/llmemory/reflection/reflector.rb +24 -20
  74. data/lib/llmemory/retrieval/bm25_scorer.rb +1 -1
  75. data/lib/llmemory/retrieval/engine.rb +25 -16
  76. data/lib/llmemory/retrieval/mmr_reranker.rb +1 -1
  77. data/lib/llmemory/short_term/session_lifecycle.rb +19 -3
  78. data/lib/llmemory/skill_mining/miner.rb +163 -0
  79. data/lib/llmemory/skill_mining.rb +8 -0
  80. data/lib/llmemory/tokenizer.rb +27 -0
  81. data/lib/llmemory/vector_store/active_record_store.rb +4 -3
  82. data/lib/llmemory/vector_store/openai_embeddings.rb +11 -7
  83. data/lib/llmemory/vector_store.rb +14 -0
  84. data/lib/llmemory/version.rb +1 -1
  85. data/lib/llmemory.rb +3 -0
  86. metadata +39 -1
@@ -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,103 @@
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, offset: nil)
46
+ scope = LlmemoryEpisode.where(user_id: user_id, archived_at: nil).order(created_at: :desc)
47
+ scope = scope.limit(limit) if limit && limit.to_i.positive?
48
+ scope = scope.offset(offset) if offset && offset.to_i.positive?
49
+ scope.map(&:data)
50
+ end
51
+
52
+ def search_episodes(user_id, query)
53
+ token_scope(LlmemoryEpisode.where(user_id: user_id, archived_at: nil), "search_text", query)
54
+ .order(created_at: :desc).map(&:data)
55
+ end
56
+
57
+ def count_episodes(user_id)
58
+ LlmemoryEpisode.where(user_id: user_id, archived_at: nil).count
59
+ end
60
+
61
+ def delete_episodes(user_id, ids)
62
+ LlmemoryEpisode.where(user_id: user_id, id: Array(ids).map(&:to_s)).delete_all
63
+ end
64
+
65
+ def archive_episodes(user_id, ids)
66
+ LlmemoryEpisode.where(user_id: user_id, id: Array(ids).map(&:to_s), archived_at: nil)
67
+ .update_all(archived_at: Time.current)
68
+ end
69
+
70
+ def expired_episode_ids(user_id, cutoff:)
71
+ LlmemoryEpisode.where(user_id: user_id, archived_at: nil).where("created_at < ?", cutoff).pluck(:id)
72
+ end
73
+
74
+ def list_users
75
+ LlmemoryEpisode.distinct.pluck(:user_id)
76
+ end
77
+
78
+ private
79
+
80
+ def token_scope(scope, column, query)
81
+ tokens = Llmemory::Tokenizer.tokenize(query)
82
+ return scope if tokens.empty?
83
+ clause = tokens.map { "LOWER(#{column}) LIKE LOWER(?)" }.join(" OR ")
84
+ scope.where(clause, *tokens.map { |t| "%#{t}%" })
85
+ end
86
+
87
+ def stringify(hash)
88
+ JSON.parse(JSON.generate(hash))
89
+ end
90
+
91
+ def searchable_text(data)
92
+ parts = [data["summary"], data["outcome"]]
93
+ Array(data["steps"]).each do |s|
94
+ next unless s.is_a?(Hash)
95
+ parts << s["observation"] << s["action"] << s["result"]
96
+ end
97
+ parts.compact.join("\n")
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -16,8 +16,8 @@ module Llmemory
16
16
  raise NotImplementedError, "#{self.class}#get_episode must be implemented"
17
17
  end
18
18
 
19
- # Newest first. Optionally capped by limit.
20
- def list_episodes(user_id, limit: nil)
19
+ # Newest first. Optionally paginated with offset/limit.
20
+ def list_episodes(user_id, limit: nil, offset: nil)
21
21
  raise NotImplementedError, "#{self.class}#list_episodes must be implemented"
22
22
  end
23
23
 
@@ -34,6 +34,19 @@ module Llmemory
34
34
  raise NotImplementedError, "#{self.class}#delete_episodes must be implemented"
35
35
  end
36
36
 
37
+ # Soft-archives episodes by id (sets archived_at on the record). Archived
38
+ # episodes are excluded from list_episodes / search_episodes / count_episodes
39
+ # but remain accessible via get_episode. Returns the number archived.
40
+ def archive_episodes(user_id, ids)
41
+ raise NotImplementedError, "#{self.class}#archive_episodes must be implemented"
42
+ end
43
+
44
+ # Returns episodes whose created_at is older than the cutoff and that are
45
+ # not already archived. Used by the TTL maintenance job.
46
+ def expired_episode_ids(user_id, cutoff:)
47
+ raise NotImplementedError, "#{self.class}#expired_episode_ids must be implemented"
48
+ end
49
+
37
50
  def list_users
38
51
  raise NotImplementedError, "#{self.class}#list_users must be implemented"
39
52
  end
@@ -0,0 +1,156 @@
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, offset: nil)
42
+ ensure_tables!
43
+ sql = "SELECT data FROM llmemory_episodes WHERE user_id = $1 AND archived_at IS NULL ORDER BY created_at DESC"
44
+ sql += " LIMIT #{limit.to_i}" if limit && limit.to_i.positive?
45
+ sql += " OFFSET #{offset.to_i}" if offset && offset.to_i.positive?
46
+ conn.exec_params(sql, [user_id]).map { |r| parse_data(r["data"]) }
47
+ end
48
+
49
+ def search_episodes(user_id, query)
50
+ ensure_tables!
51
+ suffix, params = token_filter("search_text", query, 2)
52
+ conn.exec_params(
53
+ "SELECT data FROM llmemory_episodes WHERE user_id = $1 AND archived_at IS NULL#{suffix} ORDER BY created_at DESC",
54
+ [user_id, *params]
55
+ ).map { |r| parse_data(r["data"]) }
56
+ end
57
+
58
+ def count_episodes(user_id)
59
+ ensure_tables!
60
+ conn.exec_params("SELECT COUNT(*) AS c FROM llmemory_episodes WHERE user_id = $1 AND archived_at IS NULL", [user_id]).first["c"].to_i
61
+ end
62
+
63
+ def delete_episodes(user_id, ids)
64
+ ensure_tables!
65
+ Array(ids).sum do |id|
66
+ conn.exec_params("DELETE FROM llmemory_episodes WHERE user_id = $1 AND id = $2", [user_id, id]).cmd_tuples
67
+ end
68
+ end
69
+
70
+ def archive_episodes(user_id, ids)
71
+ ensure_tables!
72
+ Array(ids).sum do |id|
73
+ conn.exec_params(
74
+ "UPDATE llmemory_episodes SET archived_at = NOW() WHERE user_id = $1 AND id = $2 AND archived_at IS NULL",
75
+ [user_id, id]
76
+ ).cmd_tuples
77
+ end
78
+ end
79
+
80
+ def expired_episode_ids(user_id, cutoff:)
81
+ ensure_tables!
82
+ conn.exec_params(
83
+ "SELECT id FROM llmemory_episodes WHERE user_id = $1 AND archived_at IS NULL AND created_at < $2",
84
+ [user_id, cutoff.iso8601]
85
+ ).map { |r| r["id"] }
86
+ end
87
+
88
+ def list_users
89
+ ensure_tables!
90
+ conn.exec("SELECT DISTINCT user_id FROM llmemory_episodes").map { |r| r["user_id"] }
91
+ end
92
+
93
+ private
94
+
95
+ def conn
96
+ @connection ||= begin
97
+ require "pg"
98
+ PG.connect(@database_url)
99
+ end
100
+ end
101
+
102
+ def ensure_tables!
103
+ conn.exec(<<~SQL)
104
+ CREATE TABLE IF NOT EXISTS llmemory_episodes (
105
+ id TEXT NOT NULL PRIMARY KEY,
106
+ user_id TEXT NOT NULL,
107
+ data JSONB NOT NULL DEFAULT '{}'::jsonb,
108
+ search_text TEXT,
109
+ created_at TIMESTAMPTZ NOT NULL,
110
+ archived_at TIMESTAMPTZ
111
+ );
112
+ CREATE INDEX IF NOT EXISTS idx_llmemory_episodes_user_id ON llmemory_episodes(user_id);
113
+ ALTER TABLE llmemory_episodes ADD COLUMN IF NOT EXISTS archived_at TIMESTAMPTZ;
114
+ SQL
115
+ end
116
+
117
+ # OR-of-token LIKE filter (see file-based DatabaseStorage). [""] for an
118
+ # empty query => match all.
119
+ def token_filter(column, query, start_index)
120
+ tokens = Llmemory::Tokenizer.tokenize(query)
121
+ return ["", []] if tokens.empty?
122
+ likes = tokens.each_index.map { |i| "LOWER(#{column}) LIKE $#{start_index + i}" }
123
+ [" AND (#{likes.join(' OR ')})", tokens.map { |t| "%#{t}%" }]
124
+ end
125
+
126
+ def parse_data(value)
127
+ JSON.parse(value.to_s, symbolize_names: true)
128
+ rescue JSON::ParserError
129
+ {}
130
+ end
131
+
132
+ def symbolize(hash)
133
+ hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
134
+ end
135
+
136
+ def searchable_text(data)
137
+ parts = [data[:summary], data[:outcome]]
138
+ Array(data[:steps]).each do |s|
139
+ next unless s.is_a?(Hash)
140
+ parts << (s[:observation] || s["observation"])
141
+ parts << (s[:action] || s["action"])
142
+ parts << (s[:result] || s["result"])
143
+ end
144
+ parts.compact.join("\n")
145
+ end
146
+
147
+ def created_at_value(data)
148
+ ca = data[:created_at]
149
+ return Time.now.utc.iso8601 if ca.nil?
150
+ ca.respond_to?(:iso8601) ? ca.iso8601 : ca.to_s
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
@@ -29,21 +29,19 @@ module Llmemory
29
29
  load_episode(path)
30
30
  end
31
31
 
32
- def list_episodes(user_id, limit: nil)
33
- sorted = all_episodes(user_id).sort_by { |e| e[:created_at] }.reverse
32
+ def list_episodes(user_id, limit: nil, offset: nil)
33
+ sorted = active_episodes(user_id).sort_by { |e| e[:created_at] }.reverse
34
+ sorted = sorted.drop(offset.to_i) if offset && offset.to_i.positive?
34
35
  limit && limit.to_i.positive? ? sorted.first(limit.to_i) : sorted
35
36
  end
36
37
 
37
38
  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) }
39
+ return list_episodes(user_id) if query.to_s.strip.empty?
40
+ active_episodes(user_id).select { |e| Llmemory::Tokenizer.matches?(episode_text(e), query) }
41
41
  end
42
42
 
43
43
  def count_episodes(user_id)
44
- dir = user_path(user_id, "episodes")
45
- return 0 unless Dir.exist?(dir)
46
- Dir.children(dir).count { |f| f.end_with?(".json") }
44
+ active_episodes(user_id).size
47
45
  end
48
46
 
49
47
  def delete_episodes(user_id, ids)
@@ -55,6 +53,24 @@ module Llmemory
55
53
  end
56
54
  end
57
55
 
56
+ def archive_episodes(user_id, ids)
57
+ Array(ids).map(&:to_s).count do |id|
58
+ path = episode_path(user_id, id)
59
+ next false unless File.file?(path)
60
+ data = JSON.parse(File.read(path))
61
+ next false if data["archived_at"]
62
+ data["archived_at"] = Time.now.iso8601
63
+ File.write(path, JSON.generate(data))
64
+ true
65
+ end
66
+ end
67
+
68
+ def expired_episode_ids(user_id, cutoff:)
69
+ active_episodes(user_id)
70
+ .select { |e| (e[:created_at] || Time.now) < cutoff }
71
+ .map { |e| e[:id].to_s }
72
+ end
73
+
58
74
  def list_users
59
75
  return [] unless Dir.exist?(@base_path)
60
76
  Dir.children(@base_path).select { |d| Dir.exist?(File.join(@base_path, d, "episodes")) }
@@ -62,6 +78,10 @@ module Llmemory
62
78
 
63
79
  private
64
80
 
81
+ def active_episodes(user_id)
82
+ all_episodes(user_id).reject { |e| e[:archived_at] }
83
+ end
84
+
65
85
  def all_episodes(user_id)
66
86
  dir = user_path(user_id, "episodes")
67
87
  return [] unless Dir.exist?(dir)
@@ -25,19 +25,19 @@ module Llmemory
25
25
  @episodes[user_id].find { |e| e[:id] == id }
26
26
  end
27
27
 
28
- def list_episodes(user_id, limit: nil)
29
- sorted = @episodes[user_id].sort_by { |e| e[:created_at] }.reverse
28
+ def list_episodes(user_id, limit: nil, offset: nil)
29
+ sorted = active_episodes(user_id).sort_by { |e| as_time(e[:created_at]) }.reverse
30
+ sorted = sorted.drop(offset.to_i) if offset && offset.to_i.positive?
30
31
  limit && limit.to_i.positive? ? sorted.first(limit.to_i) : sorted
31
32
  end
32
33
 
33
34
  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) }
35
+ return list_episodes(user_id) if query.to_s.strip.empty?
36
+ active_episodes(user_id).select { |e| Llmemory::Tokenizer.matches?(episode_text(e), query) }
37
37
  end
38
38
 
39
39
  def count_episodes(user_id)
40
- @episodes[user_id].size
40
+ active_episodes(user_id).size
41
41
  end
42
42
 
43
43
  def delete_episodes(user_id, ids)
@@ -47,12 +47,42 @@ module Llmemory
47
47
  before - @episodes[user_id].size
48
48
  end
49
49
 
50
+ def archive_episodes(user_id, ids)
51
+ ids = Array(ids).map(&:to_s)
52
+ count = 0
53
+ @episodes[user_id].each do |e|
54
+ next unless ids.include?(e[:id].to_s)
55
+ next if e[:archived_at]
56
+ e[:archived_at] = Time.now
57
+ count += 1
58
+ end
59
+ count
60
+ end
61
+
62
+ def expired_episode_ids(user_id, cutoff:)
63
+ active_episodes(user_id)
64
+ .select { |e| as_time(e[:created_at]) < cutoff }
65
+ .map { |e| e[:id].to_s }
66
+ end
67
+
50
68
  def list_users
51
69
  @episodes.keys
52
70
  end
53
71
 
54
72
  private
55
73
 
74
+ def active_episodes(user_id)
75
+ @episodes[user_id].reject { |e| e[:archived_at] }
76
+ end
77
+
78
+ def as_time(value)
79
+ return Time.now if value.nil?
80
+ return value if value.is_a?(Time)
81
+ Time.parse(value.to_s)
82
+ rescue ArgumentError
83
+ Time.now
84
+ end
85
+
56
86
  def symbolize(hash)
57
87
  hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
58
88
  end
@@ -108,11 +108,15 @@ module Llmemory
108
108
  # --- MemoryModule uniform interface ---
109
109
 
110
110
  def write(payload, **_meta)
111
- memorize(payload)
111
+ result = nil
112
+ Llmemory::Instrumentation.instrument(:memory_write, memory_type: "file_based", user_id: @user_id) do
113
+ result = memorize(payload)
114
+ end
115
+ result
112
116
  end
113
117
 
114
- def list(user_id: nil, limit: nil)
115
- @storage.list_items(user_id: user_id || @user_id, limit: limit)
118
+ def list(user_id: nil, limit: nil, offset: nil)
119
+ @storage.list_items(user_id: user_id || @user_id, limit: limit, offset: offset)
116
120
  end
117
121
 
118
122
  def stats(user_id: nil)
@@ -120,7 +124,10 @@ module Llmemory
120
124
  end
121
125
 
122
126
  # Removes items/resources by id and records the removal in the audit log.
123
- def forget(ids:, reason: nil)
127
+ # Note: file-based storages currently implement `archive_*` as physical
128
+ # removal — `mode: :soft` and `mode: :hard` are functionally equivalent
129
+ # here. Kept for API uniformity.
130
+ def forget(ids:, reason: nil, mode: :soft)
124
131
  requested = Array(ids).map(&:to_s)
125
132
  existing = (@storage.get_all_items(@user_id) + @storage.get_all_resources(@user_id))
126
133
  .map { |r| (r[:id] || r["id"]).to_s }
@@ -128,6 +135,7 @@ module Llmemory
128
135
  @storage.archive_items(@user_id, removed)
129
136
  @storage.archive_resources(@user_id, removed)
130
137
  forget_log.record(@user_id, memory_type: "file_based", ids: removed, reason: reason)
138
+ Llmemory::Instrumentation.instrument(:memory_forget, memory_type: "file_based", user_id: @user_id, count: removed.size, mode: mode)
131
139
  removed.size
132
140
  end
133
141
 
@@ -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:)
@@ -125,17 +123,19 @@ module Llmemory
125
123
  LlmemoryCategory.distinct.pluck(:user_id)).uniq
126
124
  end
127
125
 
128
- def list_resources(user_id:, limit: nil)
126
+ def list_resources(user_id:, limit: nil, offset: nil)
129
127
  scope = LlmemoryResource.where(user_id: user_id).order(:created_at)
130
128
  scope = scope.limit(limit) if limit && limit.to_i.positive?
129
+ scope = scope.offset(offset) if offset && offset.to_i.positive?
131
130
  scope.map { |r| row_to_resource(r) }
132
131
  end
133
132
 
134
- def list_items(user_id:, category: nil, limit: nil)
133
+ def list_items(user_id:, category: nil, limit: nil, offset: nil)
135
134
  scope = LlmemoryItem.where(user_id: user_id)
136
135
  scope = scope.where(category: category) if category
137
136
  scope = scope.order(:created_at)
138
137
  scope = scope.limit(limit) if limit && limit.to_i.positive?
138
+ scope = scope.offset(offset) if offset && offset.to_i.positive?
139
139
  scope.map { |r| row_to_item(r) }
140
140
  end
141
141
 
@@ -181,6 +181,15 @@ module Llmemory
181
181
  (str || "").to_s.gsub(/[%_\\]/) { |c| "\\#{c}" }
182
182
  end
183
183
 
184
+ # OR-of-token LIKE scope for keyword search; unchanged scope (match all)
185
+ # when the query has no tokens.
186
+ def token_scope(scope, column, query)
187
+ tokens = Llmemory::Tokenizer.tokenize(query)
188
+ return scope if tokens.empty?
189
+ clause = tokens.map { "LOWER(#{column}) LIKE LOWER(?)" }.join(" OR ")
190
+ scope.where(clause, *tokens.map { |t| "%#{sanitize_like(t)}%" })
191
+ end
192
+
184
193
  def row_to_item(r)
185
194
  h = {
186
195
  id: r.id,
@@ -69,11 +69,11 @@ module Llmemory
69
69
  raise NotImplementedError, "#{self.class}#list_users must be implemented"
70
70
  end
71
71
 
72
- def list_resources(user_id:, limit: nil)
72
+ def list_resources(user_id:, limit: nil, offset: nil)
73
73
  raise NotImplementedError, "#{self.class}#list_resources must be implemented"
74
74
  end
75
75
 
76
- def list_items(user_id:, category: nil, limit: nil)
76
+ def list_items(user_id:, category: nil, limit: nil, offset: nil)
77
77
  raise NotImplementedError, "#{self.class}#list_items must be implemented"
78
78
  end
79
79
 
@@ -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
@@ -169,15 +169,16 @@ module Llmemory
169
169
  conn.exec("SELECT DISTINCT user_id FROM llmemory_categories").map { |r| r["user_id"] }).uniq
170
170
  end
171
171
 
172
- def list_resources(user_id:, limit: nil)
172
+ def list_resources(user_id:, limit: nil, offset: nil)
173
173
  ensure_tables!
174
174
  sql = "SELECT id, text, created_at FROM llmemory_resources WHERE user_id = $1 ORDER BY created_at"
175
175
  sql += " LIMIT #{limit.to_i}" if limit && limit.to_i.positive?
176
+ sql += " OFFSET #{offset.to_i}" if offset && offset.to_i.positive?
176
177
  rows = conn.exec_params(sql, [user_id])
177
178
  rows_to_resources(rows)
178
179
  end
179
180
 
180
- def list_items(user_id:, category: nil, limit: nil)
181
+ def list_items(user_id:, category: nil, limit: nil, offset: nil)
181
182
  ensure_tables!
182
183
  sql = "SELECT id, category, content, source_resource_id, importance, provenance, created_at FROM llmemory_items WHERE user_id = $1"
183
184
  params = [user_id]
@@ -187,6 +188,7 @@ module Llmemory
187
188
  end
188
189
  sql += " ORDER BY created_at"
189
190
  sql += " LIMIT #{limit.to_i}" if limit && limit.to_i.positive?
191
+ sql += " OFFSET #{offset.to_i}" if offset && offset.to_i.positive?
190
192
  rows = params.size == 1 ? conn.exec_params(sql, params) : conn.exec_params(sql, params)
191
193
  rows_to_items(rows)
192
194
  end
@@ -290,6 +292,16 @@ module Llmemory
290
292
  end
291
293
  end
292
294
 
295
+ # Builds an OR-of-token LIKE filter for keyword search. Returns
296
+ # ["" , []] for an empty query (match all). Tokens are [a-z0-9]{2,} so
297
+ # they carry no LIKE wildcards.
298
+ def token_filter(column, query, start_index)
299
+ tokens = Llmemory::Tokenizer.tokenize(query)
300
+ return ["", []] if tokens.empty?
301
+ likes = tokens.each_index.map { |i| "LOWER(#{column}) LIKE $#{start_index + i}" }
302
+ [" AND (#{likes.join(' OR ')})", tokens.map { |t| "%#{t}%" }]
303
+ end
304
+
293
305
  def parse_provenance(value)
294
306
  return nil if value.nil? || value.to_s.strip.empty?
295
307
  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:)
@@ -154,14 +152,16 @@ module Llmemory
154
152
  Dir.children(@base_path).select { |e| File.directory?(File.join(@base_path, e)) && !e.start_with?(".") }
155
153
  end
156
154
 
157
- def list_resources(user_id:, limit: nil)
155
+ def list_resources(user_id:, limit: nil, offset: nil)
158
156
  list = get_all_resources(user_id)
157
+ list = list.drop(offset.to_i) if offset && offset.to_i.positive?
159
158
  limit ? list.take(limit) : list
160
159
  end
161
160
 
162
- def list_items(user_id:, category: nil, limit: nil)
161
+ def list_items(user_id:, category: nil, limit: nil, offset: nil)
163
162
  list = get_all_items(user_id)
164
163
  list = list.select { |i| (i[:category] || i["category"]).to_s == category.to_s } if category
164
+ list = list.drop(offset.to_i) if offset && offset.to_i.positive?
165
165
  list = list.take(limit) if limit
166
166
  list
167
167
  end