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,114 @@
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 Procedural
11
+ module Storages
12
+ # ActiveRecord backend. Stores each skill as a JSONB `data` document; AR
13
+ # auto-deserializes jsonb to a Hash (string keys), which Skill.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_skill(user_id, skill)
28
+ id = skill[:id] || skill["id"] || "skill_#{SecureRandom.hex(8)}"
29
+ data = stringify(skill).merge("id" => id, "user_id" => user_id)
30
+ data["created_at"] ||= Time.now.utc.iso8601
31
+ rec = LlmemorySkill.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_skill(user_id, id)
41
+ LlmemorySkill.find_by(user_id: user_id, id: id)&.data
42
+ end
43
+
44
+ def list_skills(user_id, limit: nil, offset: nil)
45
+ scope = LlmemorySkill.where(user_id: user_id, archived_at: nil).order(created_at: :desc)
46
+ scope = scope.limit(limit) if limit && limit.to_i.positive?
47
+ scope = scope.offset(offset) if offset && offset.to_i.positive?
48
+ scope.map(&:data)
49
+ end
50
+
51
+ def search_skills(user_id, query)
52
+ token_scope(LlmemorySkill.where(user_id: user_id, archived_at: nil), "search_text", query)
53
+ .order(created_at: :desc).map(&:data)
54
+ end
55
+
56
+ def find_skills_by_name(user_id, name)
57
+ LlmemorySkill.where(user_id: user_id, archived_at: nil).where("data->>'name' = ?", name.to_s).map(&:data)
58
+ end
59
+
60
+ def record_outcome(user_id, skill_id, success:)
61
+ rec = LlmemorySkill.find_by(user_id: user_id, id: skill_id)
62
+ return nil unless rec
63
+ data = rec.data || {}
64
+ key = success ? "success_count" : "failure_count"
65
+ data[key] = (data[key] || 0).to_i + 1
66
+ data["updated_at"] = Time.now.utc.iso8601
67
+ rec.data = data
68
+ rec.search_text = searchable_text(data)
69
+ rec.save!
70
+ data
71
+ end
72
+
73
+ def count_skills(user_id)
74
+ LlmemorySkill.where(user_id: user_id, archived_at: nil).count
75
+ end
76
+
77
+ def delete_skills(user_id, ids)
78
+ LlmemorySkill.where(user_id: user_id, id: Array(ids).map(&:to_s)).delete_all
79
+ end
80
+
81
+ def archive_skills(user_id, ids)
82
+ LlmemorySkill.where(user_id: user_id, id: Array(ids).map(&:to_s), archived_at: nil)
83
+ .update_all(archived_at: Time.current)
84
+ end
85
+
86
+ def expired_skill_ids(user_id, cutoff:)
87
+ LlmemorySkill.where(user_id: user_id, archived_at: nil).where("created_at < ?", cutoff).pluck(:id)
88
+ end
89
+
90
+ def list_users
91
+ LlmemorySkill.distinct.pluck(:user_id)
92
+ end
93
+
94
+ private
95
+
96
+ def token_scope(scope, column, query)
97
+ tokens = Llmemory::Tokenizer.tokenize(query)
98
+ return scope if tokens.empty?
99
+ clause = tokens.map { "LOWER(#{column}) LIKE LOWER(?)" }.join(" OR ")
100
+ scope.where(clause, *tokens.map { |t| "%#{t}%" })
101
+ end
102
+
103
+ def stringify(hash)
104
+ JSON.parse(JSON.generate(hash))
105
+ end
106
+
107
+ def searchable_text(data)
108
+ [data["name"], data["description"], data["body"]].compact.join("\n")
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -16,7 +16,7 @@ module Llmemory
16
16
  raise NotImplementedError, "#{self.class}#get_skill must be implemented"
17
17
  end
18
18
 
19
- def list_skills(user_id, limit: nil)
19
+ def list_skills(user_id, limit: nil, offset: nil)
20
20
  raise NotImplementedError, "#{self.class}#list_skills must be implemented"
21
21
  end
22
22
 
@@ -43,6 +43,19 @@ module Llmemory
43
43
  raise NotImplementedError, "#{self.class}#delete_skills must be implemented"
44
44
  end
45
45
 
46
+ # Soft-archives skills by id. Archived skills are excluded from
47
+ # list_skills / search_skills / count_skills but remain accessible via
48
+ # get_skill. Returns the number archived.
49
+ def archive_skills(user_id, ids)
50
+ raise NotImplementedError, "#{self.class}#archive_skills must be implemented"
51
+ end
52
+
53
+ # Returns skills whose created_at is older than the cutoff and that are
54
+ # not already archived. Used by the TTL maintenance job.
55
+ def expired_skill_ids(user_id, cutoff:)
56
+ raise NotImplementedError, "#{self.class}#expired_skill_ids must be implemented"
57
+ end
58
+
46
59
  def list_users
47
60
  raise NotImplementedError, "#{self.class}#list_users must be implemented"
48
61
  end
@@ -0,0 +1,169 @@
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 Procedural
11
+ module Storages
12
+ # PostgreSQL backend. Each skill is stored as a JSONB `data` document
13
+ # (plus id/user_id/created_at and a denormalized search_text), mirroring
14
+ # 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_skill(user_id, skill)
22
+ ensure_tables!
23
+ id = skill[:id] || skill["id"] || "skill_#{SecureRandom.hex(8)}"
24
+ data = symbolize(skill).merge(id: id, user_id: user_id)
25
+ data[:created_at] ||= Time.now.utc.iso8601
26
+ conn.exec_params(
27
+ "INSERT INTO llmemory_skills (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_skill(user_id, id)
36
+ ensure_tables!
37
+ rows = conn.exec_params("SELECT data FROM llmemory_skills 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_skills(user_id, limit: nil, offset: nil)
42
+ ensure_tables!
43
+ sql = "SELECT data FROM llmemory_skills 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_skills(user_id, query)
50
+ ensure_tables!
51
+ suffix, params = token_filter("search_text", query, 2)
52
+ conn.exec_params(
53
+ "SELECT data FROM llmemory_skills 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 find_skills_by_name(user_id, name)
59
+ ensure_tables!
60
+ conn.exec_params(
61
+ "SELECT data FROM llmemory_skills WHERE user_id = $1 AND archived_at IS NULL AND data->>'name' = $2",
62
+ [user_id, name.to_s]
63
+ ).map { |r| parse_data(r["data"]) }
64
+ end
65
+
66
+ def record_outcome(user_id, skill_id, success:)
67
+ ensure_tables!
68
+ data = get_skill(user_id, skill_id)
69
+ return nil unless data
70
+ key = success ? :success_count : :failure_count
71
+ data[key] = (data[key] || 0).to_i + 1
72
+ data[:updated_at] = Time.now.utc.iso8601
73
+ conn.exec_params(
74
+ "UPDATE llmemory_skills SET data = $3::jsonb, search_text = $4 WHERE user_id = $1 AND id = $2",
75
+ [user_id, skill_id, JSON.generate(data), searchable_text(data)]
76
+ )
77
+ data
78
+ end
79
+
80
+ def count_skills(user_id)
81
+ ensure_tables!
82
+ conn.exec_params("SELECT COUNT(*) AS c FROM llmemory_skills WHERE user_id = $1 AND archived_at IS NULL", [user_id]).first["c"].to_i
83
+ end
84
+
85
+ def delete_skills(user_id, ids)
86
+ ensure_tables!
87
+ Array(ids).sum do |id|
88
+ conn.exec_params("DELETE FROM llmemory_skills WHERE user_id = $1 AND id = $2", [user_id, id]).cmd_tuples
89
+ end
90
+ end
91
+
92
+ def archive_skills(user_id, ids)
93
+ ensure_tables!
94
+ Array(ids).sum do |id|
95
+ conn.exec_params(
96
+ "UPDATE llmemory_skills SET archived_at = NOW() WHERE user_id = $1 AND id = $2 AND archived_at IS NULL",
97
+ [user_id, id]
98
+ ).cmd_tuples
99
+ end
100
+ end
101
+
102
+ def expired_skill_ids(user_id, cutoff:)
103
+ ensure_tables!
104
+ conn.exec_params(
105
+ "SELECT id FROM llmemory_skills WHERE user_id = $1 AND archived_at IS NULL AND created_at < $2",
106
+ [user_id, cutoff.iso8601]
107
+ ).map { |r| r["id"] }
108
+ end
109
+
110
+ def list_users
111
+ ensure_tables!
112
+ conn.exec("SELECT DISTINCT user_id FROM llmemory_skills").map { |r| r["user_id"] }
113
+ end
114
+
115
+ private
116
+
117
+ def conn
118
+ @connection ||= begin
119
+ require "pg"
120
+ PG.connect(@database_url)
121
+ end
122
+ end
123
+
124
+ def ensure_tables!
125
+ conn.exec(<<~SQL)
126
+ CREATE TABLE IF NOT EXISTS llmemory_skills (
127
+ id TEXT NOT NULL PRIMARY KEY,
128
+ user_id TEXT NOT NULL,
129
+ data JSONB NOT NULL DEFAULT '{}'::jsonb,
130
+ search_text TEXT,
131
+ created_at TIMESTAMPTZ NOT NULL,
132
+ archived_at TIMESTAMPTZ
133
+ );
134
+ CREATE INDEX IF NOT EXISTS idx_llmemory_skills_user_id ON llmemory_skills(user_id);
135
+ ALTER TABLE llmemory_skills ADD COLUMN IF NOT EXISTS archived_at TIMESTAMPTZ;
136
+ SQL
137
+ end
138
+
139
+ def token_filter(column, query, start_index)
140
+ tokens = Llmemory::Tokenizer.tokenize(query)
141
+ return ["", []] if tokens.empty?
142
+ likes = tokens.each_index.map { |i| "LOWER(#{column}) LIKE $#{start_index + i}" }
143
+ [" AND (#{likes.join(' OR ')})", tokens.map { |t| "%#{t}%" }]
144
+ end
145
+
146
+ def parse_data(value)
147
+ JSON.parse(value.to_s, symbolize_names: true)
148
+ rescue JSON::ParserError
149
+ {}
150
+ end
151
+
152
+ def symbolize(hash)
153
+ hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
154
+ end
155
+
156
+ def searchable_text(data)
157
+ [data[:name], data[:description], data[:body]].compact.join("\n")
158
+ end
159
+
160
+ def created_at_value(data)
161
+ ca = data[:created_at]
162
+ return Time.now.utc.iso8601 if ca.nil?
163
+ ca.respond_to?(:iso8601) ? ca.iso8601 : ca.to_s
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
@@ -29,19 +29,19 @@ module Llmemory
29
29
  load_skill(path)
30
30
  end
31
31
 
32
- def list_skills(user_id, limit: nil)
33
- sorted = all_skills(user_id).sort_by { |s| s[:created_at] }.reverse
32
+ def list_skills(user_id, limit: nil, offset: nil)
33
+ sorted = active_skills(user_id).sort_by { |s| s[: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_skills(user_id, query)
38
- q = query.to_s.downcase
39
- return list_skills(user_id) if q.strip.empty?
40
- all_skills(user_id).select { |s| skill_text(s).downcase.include?(q) }
39
+ return list_skills(user_id) if query.to_s.strip.empty?
40
+ active_skills(user_id).select { |s| Llmemory::Tokenizer.matches?(skill_text(s), query) }
41
41
  end
42
42
 
43
43
  def find_skills_by_name(user_id, name)
44
- all_skills(user_id).select { |s| s[:name].to_s == name.to_s }
44
+ active_skills(user_id).select { |s| s[:name].to_s == name.to_s }
45
45
  end
46
46
 
47
47
  def record_outcome(user_id, skill_id, success:)
@@ -55,9 +55,7 @@ module Llmemory
55
55
  end
56
56
 
57
57
  def count_skills(user_id)
58
- dir = user_path(user_id, "skills")
59
- return 0 unless Dir.exist?(dir)
60
- Dir.children(dir).count { |f| f.end_with?(".json") }
58
+ active_skills(user_id).size
61
59
  end
62
60
 
63
61
  def delete_skills(user_id, ids)
@@ -69,6 +67,24 @@ module Llmemory
69
67
  end
70
68
  end
71
69
 
70
+ def archive_skills(user_id, ids)
71
+ Array(ids).map(&:to_s).count do |id|
72
+ path = skill_path(user_id, id)
73
+ next false unless File.file?(path)
74
+ data = JSON.parse(File.read(path))
75
+ next false if data["archived_at"]
76
+ data["archived_at"] = Time.now.iso8601
77
+ File.write(path, JSON.generate(data))
78
+ true
79
+ end
80
+ end
81
+
82
+ def expired_skill_ids(user_id, cutoff:)
83
+ active_skills(user_id)
84
+ .select { |s| (s[:created_at] || Time.now) < cutoff }
85
+ .map { |s| s[:id].to_s }
86
+ end
87
+
72
88
  def list_users
73
89
  return [] unless Dir.exist?(@base_path)
74
90
  Dir.children(@base_path).select { |d| Dir.exist?(File.join(@base_path, d, "skills")) }
@@ -76,6 +92,10 @@ module Llmemory
76
92
 
77
93
  private
78
94
 
95
+ def active_skills(user_id)
96
+ all_skills(user_id).reject { |s| s[:archived_at] }
97
+ end
98
+
79
99
  def all_skills(user_id)
80
100
  dir = user_path(user_id, "skills")
81
101
  return [] unless Dir.exist?(dir)
@@ -25,19 +25,19 @@ module Llmemory
25
25
  @skills[user_id].find { |s| s[:id] == id }
26
26
  end
27
27
 
28
- def list_skills(user_id, limit: nil)
29
- sorted = @skills[user_id].sort_by { |s| s[:created_at] }.reverse
28
+ def list_skills(user_id, limit: nil, offset: nil)
29
+ sorted = active_skills(user_id).sort_by { |s| as_time(s[: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_skills(user_id, query)
34
- q = query.to_s.downcase
35
- return list_skills(user_id) if q.strip.empty?
36
- @skills[user_id].select { |s| skill_text(s).downcase.include?(q) }
35
+ return list_skills(user_id) if query.to_s.strip.empty?
36
+ active_skills(user_id).select { |s| Llmemory::Tokenizer.matches?(skill_text(s), query) }
37
37
  end
38
38
 
39
39
  def find_skills_by_name(user_id, name)
40
- @skills[user_id].select { |s| s[:name].to_s == name.to_s }
40
+ active_skills(user_id).select { |s| s[:name].to_s == name.to_s }
41
41
  end
42
42
 
43
43
  def record_outcome(user_id, skill_id, success:)
@@ -50,7 +50,7 @@ module Llmemory
50
50
  end
51
51
 
52
52
  def count_skills(user_id)
53
- @skills[user_id].size
53
+ active_skills(user_id).size
54
54
  end
55
55
 
56
56
  def delete_skills(user_id, ids)
@@ -60,12 +60,42 @@ module Llmemory
60
60
  before - @skills[user_id].size
61
61
  end
62
62
 
63
+ def archive_skills(user_id, ids)
64
+ ids = Array(ids).map(&:to_s)
65
+ count = 0
66
+ @skills[user_id].each do |s|
67
+ next unless ids.include?(s[:id].to_s)
68
+ next if s[:archived_at]
69
+ s[:archived_at] = Time.now
70
+ count += 1
71
+ end
72
+ count
73
+ end
74
+
75
+ def expired_skill_ids(user_id, cutoff:)
76
+ active_skills(user_id)
77
+ .select { |s| as_time(s[:created_at]) < cutoff }
78
+ .map { |s| s[:id].to_s }
79
+ end
80
+
63
81
  def list_users
64
82
  @skills.keys
65
83
  end
66
84
 
67
85
  private
68
86
 
87
+ def active_skills(user_id)
88
+ @skills[user_id].reject { |s| s[:archived_at] }
89
+ end
90
+
91
+ def as_time(value)
92
+ return Time.now if value.nil?
93
+ return value if value.is_a?(Time)
94
+ Time.parse(value.to_s)
95
+ rescue ArgumentError
96
+ Time.now
97
+ end
98
+
69
99
  def symbolize(hash)
70
100
  hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
71
101
  end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module Maintenance
5
+ # The cognitive maintenance pass closes CoALA's learning loop in one
6
+ # scheduled step. Independently, the gem exposes consolidation (short-term ->
7
+ # semantic), reflection (episodic -> insights), skill mining (episodic ->
8
+ # procedural) and TTL expiry. This pass orchestrates them so an agent learns
9
+ # from its experience and keeps its memory healthy without the consumer
10
+ # wiring each step by hand.
11
+ #
12
+ # Designed to run as a maintenance task (cron / Rails Job), per user. Each
13
+ # step is isolated: a failure in one is captured in the returned report
14
+ # (`:errors`) and never aborts the others.
15
+ #
16
+ # Returns:
17
+ # {
18
+ # consolidated: true/false/nil, # nil when no `memory:` was supplied
19
+ # insights: [insight_id, ...],
20
+ # mined: [proposal_or_skill_id, ...],
21
+ # expired: { episodic: N, procedural: M },
22
+ # errors: { reflect: "...", mine: "...", ... } # only failed steps
23
+ # }
24
+ class CognitivePass
25
+ def self.run!(user_id, **kwargs)
26
+ new(user_id, **kwargs).run!
27
+ end
28
+
29
+ def initialize(user_id, memory: nil, episodic: nil, procedural: nil, semantic: nil,
30
+ llm: nil, reflect: true, mine_skills: nil, expire: true,
31
+ reflection_window: 10, mining_window: Llmemory::SkillMining::Miner::DEFAULT_WINDOW)
32
+ @user_id = user_id
33
+ @memory = memory
34
+ @episodic = episodic
35
+ @procedural = procedural
36
+ @semantic = semantic
37
+ @llm = llm
38
+ @reflect = reflect
39
+ @mine_skills = mine_skills.nil? ? Llmemory.configuration.skill_mining_enabled : mine_skills
40
+ @expire = expire
41
+ @reflection_window = reflection_window
42
+ @mining_window = mining_window
43
+ end
44
+
45
+ def run!
46
+ report = { consolidated: nil, insights: [], mined: [], expired: { episodic: 0, procedural: 0 }, errors: {} }
47
+
48
+ step(report, :consolidate) { report[:consolidated] = consolidate } if @memory
49
+ step(report, :reflect) { report[:insights] = reflect } if @reflect
50
+ step(report, :mine) { report[:mined] = mine } if @mine_skills
51
+ step(report, :expire) { report[:expired] = expire } if @expire
52
+
53
+ report
54
+ end
55
+
56
+ private
57
+
58
+ def step(report, name)
59
+ yield
60
+ rescue StandardError => e
61
+ report[:errors][name] = e.message
62
+ end
63
+
64
+ def consolidate
65
+ @memory.consolidate!
66
+ end
67
+
68
+ def reflect
69
+ Reflection::Reflector.new(episodic: episodic, semantic: semantic, llm: @llm)
70
+ .reflect(window: @reflection_window)
71
+ end
72
+
73
+ def mine
74
+ SkillMining::Miner.new(episodic: episodic, procedural: procedural, llm: @llm)
75
+ .mine(window: @mining_window, auto_register: true)
76
+ end
77
+
78
+ def expire
79
+ TTLExpiry.run!(@user_id, episodic: episodic, procedural: procedural)
80
+ end
81
+
82
+ def episodic
83
+ @episodic ||= @memory&.episodic || Llmemory::LongTerm::Episodic::Memory.new(user_id: @user_id)
84
+ end
85
+
86
+ def procedural
87
+ @procedural ||= @memory&.procedural || Llmemory::LongTerm::Procedural::Memory.new(user_id: @user_id)
88
+ end
89
+
90
+ def semantic
91
+ @semantic ||= build_semantic
92
+ end
93
+
94
+ def build_semantic
95
+ llm_opts = @llm ? { llm: @llm } : {}
96
+ case (Llmemory.configuration.long_term_type || :file_based).to_s.to_sym
97
+ when :graph_based
98
+ Llmemory::LongTerm::GraphBased::Memory.new(
99
+ user_id: @user_id, storage: Llmemory::LongTerm::GraphBased::Storages.build, **llm_opts
100
+ )
101
+ else
102
+ Llmemory::LongTerm::FileBased::Memory.new(
103
+ user_id: @user_id, storage: Llmemory::LongTerm::FileBased::Storages.build, **llm_opts
104
+ )
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module Maintenance
5
+ # TTL expiry job: soft-archives episodic/procedural entries whose age
6
+ # exceeds the configured per-type TTL. Designed to run as a maintenance
7
+ # task (cron / Rails Job). Idempotent — already-archived entries are
8
+ # skipped by the storage layer.
9
+ #
10
+ # Reads `Llmemory.configuration.ttl_episodic_days` and
11
+ # `Llmemory.configuration.ttl_procedural_days`. A nil/zero TTL disables
12
+ # expiry for that memory type.
13
+ #
14
+ # Returns a hash `{ episodic: N, procedural: M }` with the number of
15
+ # entries archived per type for the given user.
16
+ class TTLExpiry
17
+ DEFAULT_REASON = "ttl_expired"
18
+
19
+ def self.run!(user_id, episodic: nil, procedural: nil, reason: DEFAULT_REASON)
20
+ new(user_id, episodic: episodic, procedural: procedural, reason: reason).run!
21
+ end
22
+
23
+ def initialize(user_id, episodic: nil, procedural: nil, reason: DEFAULT_REASON)
24
+ @user_id = user_id
25
+ @episodic = episodic
26
+ @procedural = procedural
27
+ @reason = reason
28
+ end
29
+
30
+ def run!
31
+ {
32
+ episodic: expire(memory: @episodic ||= Llmemory::LongTerm::Episodic::Memory.new(user_id: @user_id),
33
+ ttl_days: Llmemory.configuration.ttl_episodic_days),
34
+ procedural: expire(memory: @procedural ||= Llmemory::LongTerm::Procedural::Memory.new(user_id: @user_id),
35
+ ttl_days: Llmemory.configuration.ttl_procedural_days)
36
+ }
37
+ end
38
+
39
+ private
40
+
41
+ def expire(memory:, ttl_days:)
42
+ return 0 unless ttl_days && ttl_days.to_f.positive?
43
+ cutoff = Time.now - (ttl_days.to_f * 86400)
44
+ ids = memory.expired_ids(cutoff: cutoff)
45
+ return 0 if ids.empty?
46
+ memory.forget(ids: ids, reason: @reason, mode: :soft)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "maintenance/runner"
4
+ require_relative "maintenance/ttl_expiry"
5
+ require_relative "maintenance/cognitive_pass"
4
6
 
5
7
  module Llmemory
6
8
  module Maintenance
@@ -11,6 +11,14 @@ require_relative "tools/memory_consolidate"
11
11
  require_relative "tools/memory_stats"
12
12
  require_relative "tools/memory_info"
13
13
  require_relative "tools/memory_timeline_context"
14
+ require_relative "tools/memory_episode_record"
15
+ require_relative "tools/memory_episodes"
16
+ require_relative "tools/memory_skill_register"
17
+ require_relative "tools/memory_skill_report"
18
+ require_relative "tools/memory_skills"
19
+ require_relative "tools/memory_forget"
20
+ require_relative "tools/memory_mine_skills"
21
+ require_relative "tools/memory_maintain"
14
22
 
15
23
  module Llmemory
16
24
  module MCP
@@ -157,7 +165,15 @@ module Llmemory
157
165
  Tools::MemoryAddMessage,
158
166
  Tools::MemoryConsolidate,
159
167
  Tools::MemoryStats,
160
- Tools::MemoryInfo
168
+ Tools::MemoryInfo,
169
+ Tools::MemoryEpisodeRecord,
170
+ Tools::MemoryEpisodes,
171
+ Tools::MemorySkillRegister,
172
+ Tools::MemorySkillReport,
173
+ Tools::MemorySkills,
174
+ Tools::MemoryForget,
175
+ Tools::MemoryMineSkills,
176
+ Tools::MemoryMaintain
161
177
  ]
162
178
  end
163
179