llmemory 0.2.1 → 0.2.3

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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +78 -1
  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 +2 -0
  19. data/lib/llmemory/cli/commands/maintain.rb +62 -0
  20. data/lib/llmemory/cli/commands/mine_skills.rb +50 -0
  21. data/lib/llmemory/cli.rb +6 -0
  22. data/lib/llmemory/configuration.rb +28 -2
  23. data/lib/llmemory/crypto/cipher.rb +147 -0
  24. data/lib/llmemory/crypto/field_helpers.rb +110 -0
  25. data/lib/llmemory/instrumentation.rb +33 -0
  26. data/lib/llmemory/llm/anthropic.rb +21 -16
  27. data/lib/llmemory/llm/openai.rb +18 -13
  28. data/lib/llmemory/long_term/episodic/memory.rb +27 -13
  29. data/lib/llmemory/long_term/episodic/storage.rb +11 -4
  30. data/lib/llmemory/long_term/episodic/storages/active_record_storage.rb +33 -10
  31. data/lib/llmemory/long_term/episodic/storages/base.rb +15 -2
  32. data/lib/llmemory/long_term/episodic/storages/database_storage.rb +51 -8
  33. data/lib/llmemory/long_term/episodic/storages/file_storage.rb +47 -9
  34. data/lib/llmemory/long_term/episodic/storages/memory_storage.rb +35 -4
  35. data/lib/llmemory/long_term/file_based/memory.rb +12 -4
  36. data/lib/llmemory/long_term/file_based/storage.rb +11 -4
  37. data/lib/llmemory/long_term/file_based/storages/active_record_storage.rb +20 -12
  38. data/lib/llmemory/long_term/file_based/storages/base.rb +2 -2
  39. data/lib/llmemory/long_term/file_based/storages/database_storage.rb +28 -10
  40. data/lib/llmemory/long_term/file_based/storages/file_storage.rb +32 -16
  41. data/lib/llmemory/long_term/file_based/storages/memory_storage.rb +4 -2
  42. data/lib/llmemory/long_term/graph_based/memory.rb +16 -7
  43. data/lib/llmemory/long_term/graph_based/storage.rb +3 -2
  44. data/lib/llmemory/long_term/graph_based/storages/active_record_storage.rb +51 -23
  45. data/lib/llmemory/long_term/graph_based/storages/base.rb +2 -2
  46. data/lib/llmemory/long_term/graph_based/storages/memory_storage.rb +4 -2
  47. data/lib/llmemory/long_term/procedural/memory.rb +30 -16
  48. data/lib/llmemory/long_term/procedural/skill.rb +6 -2
  49. data/lib/llmemory/long_term/procedural/storage.rb +11 -4
  50. data/lib/llmemory/long_term/procedural/storages/active_record_storage.rb +47 -17
  51. data/lib/llmemory/long_term/procedural/storages/base.rb +14 -1
  52. data/lib/llmemory/long_term/procedural/storages/database_storage.rb +52 -10
  53. data/lib/llmemory/long_term/procedural/storages/file_storage.rb +49 -11
  54. data/lib/llmemory/long_term/procedural/storages/memory_storage.rb +36 -5
  55. data/lib/llmemory/maintenance/cognitive_pass.rb +109 -0
  56. data/lib/llmemory/maintenance/ttl_expiry.rb +50 -0
  57. data/lib/llmemory/maintenance.rb +2 -0
  58. data/lib/llmemory/mcp/server.rb +5 -1
  59. data/lib/llmemory/mcp/tools/memory_maintain.rb +53 -0
  60. data/lib/llmemory/mcp/tools/memory_mine_skills.rb +53 -0
  61. data/lib/llmemory/memory.rb +60 -8
  62. data/lib/llmemory/memory_module.rb +13 -6
  63. data/lib/llmemory/reflection/reflector.rb +24 -20
  64. data/lib/llmemory/retrieval/engine.rb +25 -16
  65. data/lib/llmemory/short_term/checkpoint.rb +3 -2
  66. data/lib/llmemory/short_term/stores/active_record_store.rb +12 -10
  67. data/lib/llmemory/short_term/stores/memory_store.rb +1 -1
  68. data/lib/llmemory/short_term/stores/postgres_store.rb +11 -5
  69. data/lib/llmemory/short_term/stores/redis_store.rb +7 -5
  70. data/lib/llmemory/short_term/stores.rb +7 -6
  71. data/lib/llmemory/skill_mining/miner.rb +163 -0
  72. data/lib/llmemory/skill_mining.rb +8 -0
  73. data/lib/llmemory/vector_store/active_record_store.rb +24 -3
  74. data/lib/llmemory/vector_store/memory_store.rb +23 -3
  75. data/lib/llmemory/vector_store/openai_embeddings.rb +11 -7
  76. data/lib/llmemory/vector_store.rb +4 -3
  77. data/lib/llmemory/version.rb +1 -1
  78. data/lib/llmemory.rb +4 -0
  79. metadata +24 -1
@@ -17,10 +17,11 @@ module Llmemory
17
17
  DEFAULT_KIND = "prompt"
18
18
 
19
19
  attr_reader :id, :user_id, :name, :description, :body, :kind, :version,
20
- :success_count, :failure_count, :created_at, :updated_at
20
+ :success_count, :failure_count, :provenance, :created_at, :updated_at
21
21
 
22
22
  def initialize(id:, user_id:, name:, body:, description: nil, kind: DEFAULT_KIND,
23
- version: 1, success_count: 0, failure_count: 0, created_at: nil, updated_at: nil)
23
+ version: 1, success_count: 0, failure_count: 0, provenance: nil,
24
+ created_at: nil, updated_at: nil)
24
25
  @id = id
25
26
  @user_id = user_id
26
27
  @name = name.to_s
@@ -30,6 +31,7 @@ module Llmemory
30
31
  @version = version.to_i
31
32
  @success_count = success_count.to_i
32
33
  @failure_count = failure_count.to_i
34
+ @provenance = provenance
33
35
  @created_at = created_at || Time.now
34
36
  @updated_at = updated_at || @created_at
35
37
  end
@@ -60,6 +62,7 @@ module Llmemory
60
62
  version: hash[:version] || hash["version"] || 1,
61
63
  success_count: hash[:success_count] || hash["success_count"] || 0,
62
64
  failure_count: hash[:failure_count] || hash["failure_count"] || 0,
65
+ provenance: hash[:provenance] || hash["provenance"],
63
66
  created_at: parse_time(hash[:created_at] || hash["created_at"]),
64
67
  updated_at: parse_time(hash[:updated_at] || hash["updated_at"])
65
68
  )
@@ -83,6 +86,7 @@ module Llmemory
83
86
  version: version,
84
87
  success_count: success_count,
85
88
  failure_count: failure_count,
89
+ provenance: provenance,
86
90
  created_at: created_at.respond_to?(:iso8601) ? created_at.iso8601(6) : created_at,
87
91
  updated_at: updated_at.respond_to?(:iso8601) ? updated_at.iso8601(6) : updated_at
88
92
  }
@@ -12,17 +12,24 @@ module Llmemory
12
12
  Storage = Storages::MemoryStorage
13
13
 
14
14
  module Storages
15
- def self.build(store: nil, base_path: nil, database_url: nil)
15
+ def self.build(store: nil, base_path: nil, database_url: nil, cipher: nil)
16
+ resolved_cipher = cipher || Llmemory.build_cipher
16
17
  case (store || Llmemory.configuration.long_term_store).to_s.to_sym
17
18
  when :memory
18
19
  MemoryStorage.new
19
20
  when :file
20
- FileStorage.new(base_path: base_path || Llmemory.configuration.long_term_storage_path)
21
+ FileStorage.new(
22
+ base_path: base_path || Llmemory.configuration.long_term_storage_path,
23
+ cipher: resolved_cipher
24
+ )
21
25
  when :postgres, :database
22
- DatabaseStorage.new(database_url: database_url || Llmemory.configuration.database_url)
26
+ DatabaseStorage.new(
27
+ database_url: database_url || Llmemory.configuration.database_url,
28
+ cipher: resolved_cipher
29
+ )
23
30
  when :active_record, :activerecord
24
31
  require_relative "storages/active_record_storage"
25
- ActiveRecordStorage.new
32
+ ActiveRecordStorage.new(cipher: resolved_cipher)
26
33
  else
27
34
  MemoryStorage.new
28
35
  end
@@ -4,6 +4,7 @@ require "json"
4
4
  require "securerandom"
5
5
  require "time"
6
6
  require_relative "base"
7
+ require_relative "../../../crypto/field_helpers"
7
8
 
8
9
  module Llmemory
9
10
  module LongTerm
@@ -13,7 +14,10 @@ module Llmemory
13
14
  # auto-deserializes jsonb to a Hash (string keys), which Skill.from_h
14
15
  # handles. Mirrors the file-based ActiveRecordStorage pattern.
15
16
  class ActiveRecordStorage < Base
16
- def initialize
17
+ include Llmemory::Crypto::FieldHelpers
18
+
19
+ def initialize(cipher: nil)
20
+ @cipher = cipher || Llmemory.build_cipher
17
21
  self.class.load_models!
18
22
  end
19
23
 
@@ -30,53 +34,71 @@ module Llmemory
30
34
  data["created_at"] ||= Time.now.utc.iso8601
31
35
  rec = LlmemorySkill.find_or_initialize_by(id: id)
32
36
  rec.user_id = user_id
33
- rec.data = data
34
- rec.search_text = searchable_text(data)
37
+ rec.data = cipher.enabled? ? enc_json(data) : data
38
+ rec.search_text = enc(searchable_text(data))
35
39
  rec.created_at ||= Time.current
36
40
  rec.save!
37
41
  id
38
42
  end
39
43
 
40
44
  def get_skill(user_id, id)
41
- LlmemorySkill.find_by(user_id: user_id, id: id)&.data
45
+ rec = LlmemorySkill.find_by(user_id: user_id, id: id)
46
+ return nil unless rec
47
+
48
+ decode_data(rec.data)
42
49
  end
43
50
 
44
- def list_skills(user_id, limit: nil)
45
- scope = LlmemorySkill.where(user_id: user_id).order(created_at: :desc)
51
+ def list_skills(user_id, limit: nil, offset: nil)
52
+ scope = LlmemorySkill.where(user_id: user_id, archived_at: nil).order(created_at: :desc)
46
53
  scope = scope.limit(limit) if limit && limit.to_i.positive?
47
- scope.map(&:data)
54
+ scope = scope.offset(offset) if offset && offset.to_i.positive?
55
+ scope.map { |r| decode_data(r.data) }
48
56
  end
49
57
 
50
58
  def search_skills(user_id, query)
51
- token_scope(LlmemorySkill.where(user_id: user_id), "search_text", query)
52
- .order(created_at: :desc).map(&:data)
59
+ token_scope(LlmemorySkill.where(user_id: user_id, archived_at: nil), "search_text", query)
60
+ .order(created_at: :desc).map { |r| decode_data(r.data) }
53
61
  end
54
62
 
55
63
  def find_skills_by_name(user_id, name)
56
- LlmemorySkill.where(user_id: user_id).where("data->>'name' = ?", name.to_s).map(&:data)
64
+ if cipher.enabled?
65
+ LlmemorySkill.where(user_id: user_id, archived_at: nil).map { |r| decode_data(r.data) }
66
+ .select { |s| (s[:name] || s["name"]).to_s == name.to_s }
67
+ else
68
+ LlmemorySkill.where(user_id: user_id, archived_at: nil).where("data->>'name' = ?", name.to_s).map(&:data)
69
+ end
57
70
  end
58
71
 
59
72
  def record_outcome(user_id, skill_id, success:)
60
73
  rec = LlmemorySkill.find_by(user_id: user_id, id: skill_id)
61
74
  return nil unless rec
62
- data = rec.data || {}
63
- key = success ? "success_count" : "failure_count"
75
+ data = decode_data(rec.data) || {}
76
+ key = success ? :success_count : :failure_count
64
77
  data[key] = (data[key] || 0).to_i + 1
65
- data["updated_at"] = Time.now.utc.iso8601
66
- rec.data = data
67
- rec.search_text = searchable_text(data)
78
+ data[:updated_at] = Time.now.utc.iso8601
79
+ rec.data = cipher.enabled? ? enc_json(data) : data
80
+ rec.search_text = enc(searchable_text(data))
68
81
  rec.save!
69
82
  data
70
83
  end
71
84
 
72
85
  def count_skills(user_id)
73
- LlmemorySkill.where(user_id: user_id).count
86
+ LlmemorySkill.where(user_id: user_id, archived_at: nil).count
74
87
  end
75
88
 
76
89
  def delete_skills(user_id, ids)
77
90
  LlmemorySkill.where(user_id: user_id, id: Array(ids).map(&:to_s)).delete_all
78
91
  end
79
92
 
93
+ def archive_skills(user_id, ids)
94
+ LlmemorySkill.where(user_id: user_id, id: Array(ids).map(&:to_s), archived_at: nil)
95
+ .update_all(archived_at: Time.current)
96
+ end
97
+
98
+ def expired_skill_ids(user_id, cutoff:)
99
+ LlmemorySkill.where(user_id: user_id, archived_at: nil).where("created_at < ?", cutoff).pluck(:id)
100
+ end
101
+
80
102
  def list_users
81
103
  LlmemorySkill.distinct.pluck(:user_id)
82
104
  end
@@ -95,7 +117,15 @@ module Llmemory
95
117
  end
96
118
 
97
119
  def searchable_text(data)
98
- [data["name"], data["description"], data["body"]].compact.join("\n")
120
+ h = data.is_a?(Hash) ? data : {}
121
+ [h["name"] || h[:name], h["description"] || h[:description], h["body"] || h[:body]].compact.join("\n")
122
+ end
123
+
124
+ def decode_data(raw)
125
+ return raw.transform_keys(&:to_sym) if raw.is_a?(Hash)
126
+ return dec_json(raw) if raw.is_a?(String) && cipher.encrypted?(raw)
127
+
128
+ raw
99
129
  end
100
130
  end
101
131
  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
@@ -4,6 +4,7 @@ require "json"
4
4
  require "securerandom"
5
5
  require "time"
6
6
  require_relative "base"
7
+ require_relative "../../../crypto/field_helpers"
7
8
 
8
9
  module Llmemory
9
10
  module LongTerm
@@ -13,9 +14,12 @@ module Llmemory
13
14
  # (plus id/user_id/created_at and a denormalized search_text), mirroring
14
15
  # the file-based DatabaseStorage pattern.
15
16
  class DatabaseStorage < Base
16
- def initialize(database_url: nil)
17
+ include Llmemory::Crypto::FieldHelpers
18
+
19
+ def initialize(database_url: nil, cipher: nil)
17
20
  @database_url = database_url || Llmemory.configuration.database_url
18
21
  @connection = nil
22
+ @cipher = cipher || Llmemory.build_cipher
19
23
  end
20
24
 
21
25
  def save_skill(user_id, skill)
@@ -27,7 +31,7 @@ module Llmemory
27
31
  "INSERT INTO llmemory_skills (id, user_id, data, search_text, created_at) " \
28
32
  "VALUES ($1, $2, $3::jsonb, $4, $5) " \
29
33
  "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)]
34
+ [id, user_id, store_data(data), enc(searchable_text(data)), created_at_value(data)]
31
35
  )
32
36
  id
33
37
  end
@@ -38,10 +42,11 @@ module Llmemory
38
42
  rows.any? ? parse_data(rows.first["data"]) : nil
39
43
  end
40
44
 
41
- def list_skills(user_id, limit: nil)
45
+ def list_skills(user_id, limit: nil, offset: nil)
42
46
  ensure_tables!
43
- sql = "SELECT data FROM llmemory_skills WHERE user_id = $1 ORDER BY created_at DESC"
47
+ sql = "SELECT data FROM llmemory_skills WHERE user_id = $1 AND archived_at IS NULL ORDER BY created_at DESC"
44
48
  sql += " LIMIT #{limit.to_i}" if limit && limit.to_i.positive?
49
+ sql += " OFFSET #{offset.to_i}" if offset && offset.to_i.positive?
45
50
  conn.exec_params(sql, [user_id]).map { |r| parse_data(r["data"]) }
46
51
  end
47
52
 
@@ -49,7 +54,7 @@ module Llmemory
49
54
  ensure_tables!
50
55
  suffix, params = token_filter("search_text", query, 2)
51
56
  conn.exec_params(
52
- "SELECT data FROM llmemory_skills WHERE user_id = $1#{suffix} ORDER BY created_at DESC",
57
+ "SELECT data FROM llmemory_skills WHERE user_id = $1 AND archived_at IS NULL#{suffix} ORDER BY created_at DESC",
53
58
  [user_id, *params]
54
59
  ).map { |r| parse_data(r["data"]) }
55
60
  end
@@ -57,7 +62,7 @@ module Llmemory
57
62
  def find_skills_by_name(user_id, name)
58
63
  ensure_tables!
59
64
  conn.exec_params(
60
- "SELECT data FROM llmemory_skills WHERE user_id = $1 AND data->>'name' = $2",
65
+ "SELECT data FROM llmemory_skills WHERE user_id = $1 AND archived_at IS NULL AND data->>'name' = $2",
61
66
  [user_id, name.to_s]
62
67
  ).map { |r| parse_data(r["data"]) }
63
68
  end
@@ -71,14 +76,14 @@ module Llmemory
71
76
  data[:updated_at] = Time.now.utc.iso8601
72
77
  conn.exec_params(
73
78
  "UPDATE llmemory_skills SET data = $3::jsonb, search_text = $4 WHERE user_id = $1 AND id = $2",
74
- [user_id, skill_id, JSON.generate(data), searchable_text(data)]
79
+ [user_id, skill_id, store_data(data), enc(searchable_text(data))]
75
80
  )
76
81
  data
77
82
  end
78
83
 
79
84
  def count_skills(user_id)
80
85
  ensure_tables!
81
- conn.exec_params("SELECT COUNT(*) AS c FROM llmemory_skills WHERE user_id = $1", [user_id]).first["c"].to_i
86
+ 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
82
87
  end
83
88
 
84
89
  def delete_skills(user_id, ids)
@@ -88,6 +93,24 @@ module Llmemory
88
93
  end
89
94
  end
90
95
 
96
+ def archive_skills(user_id, ids)
97
+ ensure_tables!
98
+ Array(ids).sum do |id|
99
+ conn.exec_params(
100
+ "UPDATE llmemory_skills SET archived_at = NOW() WHERE user_id = $1 AND id = $2 AND archived_at IS NULL",
101
+ [user_id, id]
102
+ ).cmd_tuples
103
+ end
104
+ end
105
+
106
+ def expired_skill_ids(user_id, cutoff:)
107
+ ensure_tables!
108
+ conn.exec_params(
109
+ "SELECT id FROM llmemory_skills WHERE user_id = $1 AND archived_at IS NULL AND created_at < $2",
110
+ [user_id, cutoff.iso8601]
111
+ ).map { |r| r["id"] }
112
+ end
113
+
91
114
  def list_users
92
115
  ensure_tables!
93
116
  conn.exec("SELECT DISTINCT user_id FROM llmemory_skills").map { |r| r["user_id"] }
@@ -109,9 +132,11 @@ module Llmemory
109
132
  user_id TEXT NOT NULL,
110
133
  data JSONB NOT NULL DEFAULT '{}'::jsonb,
111
134
  search_text TEXT,
112
- created_at TIMESTAMPTZ NOT NULL
135
+ created_at TIMESTAMPTZ NOT NULL,
136
+ archived_at TIMESTAMPTZ
113
137
  );
114
138
  CREATE INDEX IF NOT EXISTS idx_llmemory_skills_user_id ON llmemory_skills(user_id);
139
+ ALTER TABLE llmemory_skills ADD COLUMN IF NOT EXISTS archived_at TIMESTAMPTZ;
115
140
  SQL
116
141
  end
117
142
 
@@ -123,11 +148,28 @@ module Llmemory
123
148
  end
124
149
 
125
150
  def parse_data(value)
126
- JSON.parse(value.to_s, symbolize_names: true)
151
+ if value.is_a?(Hash)
152
+ return value.transform_keys(&:to_sym)
153
+ end
154
+
155
+ str = value.to_s
156
+ if cipher.encrypted?(str)
157
+ cipher.decrypt_json(str)
158
+ else
159
+ JSON.parse(str, symbolize_names: true)
160
+ end
127
161
  rescue JSON::ParserError
128
162
  {}
129
163
  end
130
164
 
165
+ def store_data(data)
166
+ if cipher.enabled?
167
+ JSON.generate(enc_json(data))
168
+ else
169
+ JSON.generate(data)
170
+ end
171
+ end
172
+
131
173
  def symbolize(hash)
132
174
  hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
133
175
  end
@@ -4,22 +4,26 @@ require "fileutils"
4
4
  require "json"
5
5
  require "time"
6
6
  require_relative "base"
7
+ require_relative "../../../crypto/field_helpers"
7
8
 
8
9
  module Llmemory
9
10
  module LongTerm
10
11
  module Procedural
11
12
  module Storages
12
13
  class FileStorage < Base
13
- def initialize(base_path: nil)
14
+ include Llmemory::Crypto::FieldHelpers
15
+
16
+ def initialize(base_path: nil, cipher: nil)
14
17
  @base_path = base_path || Llmemory.configuration.long_term_storage_path || "./llmemory_data"
15
18
  @base_path = File.expand_path(@base_path)
19
+ @cipher = cipher || Llmemory.build_cipher
16
20
  end
17
21
 
18
22
  def save_skill(user_id, skill)
19
23
  id = skill[:id] || skill["id"] || "skill_#{next_seq(user_id)}"
20
24
  data = stringify_for_json(skill).merge("id" => id, "user_id" => user_id)
21
25
  data["created_at"] ||= Time.now.iso8601(6)
22
- File.write(skill_path(user_id, id), JSON.generate(data))
26
+ write_skill_file(skill_path(user_id, id), data)
23
27
  id
24
28
  end
25
29
 
@@ -29,18 +33,19 @@ module Llmemory
29
33
  load_skill(path)
30
34
  end
31
35
 
32
- def list_skills(user_id, limit: nil)
33
- sorted = all_skills(user_id).sort_by { |s| s[:created_at] }.reverse
36
+ def list_skills(user_id, limit: nil, offset: nil)
37
+ sorted = active_skills(user_id).sort_by { |s| s[:created_at] }.reverse
38
+ sorted = sorted.drop(offset.to_i) if offset && offset.to_i.positive?
34
39
  limit && limit.to_i.positive? ? sorted.first(limit.to_i) : sorted
35
40
  end
36
41
 
37
42
  def search_skills(user_id, query)
38
43
  return list_skills(user_id) if query.to_s.strip.empty?
39
- all_skills(user_id).select { |s| Llmemory::Tokenizer.matches?(skill_text(s), query) }
44
+ active_skills(user_id).select { |s| Llmemory::Tokenizer.matches?(skill_text(s), query) }
40
45
  end
41
46
 
42
47
  def find_skills_by_name(user_id, name)
43
- all_skills(user_id).select { |s| s[:name].to_s == name.to_s }
48
+ active_skills(user_id).select { |s| s[:name].to_s == name.to_s }
44
49
  end
45
50
 
46
51
  def record_outcome(user_id, skill_id, success:)
@@ -49,14 +54,12 @@ module Llmemory
49
54
  key = success ? :success_count : :failure_count
50
55
  skill[key] = (skill[key] || 0).to_i + 1
51
56
  skill[:updated_at] = Time.now.iso8601(6)
52
- File.write(skill_path(user_id, skill_id), JSON.generate(stringify_for_json(skill)))
57
+ write_skill_file(skill_path(user_id, skill_id), stringify_for_json(skill))
53
58
  skill
54
59
  end
55
60
 
56
61
  def count_skills(user_id)
57
- dir = user_path(user_id, "skills")
58
- return 0 unless Dir.exist?(dir)
59
- Dir.children(dir).count { |f| f.end_with?(".json") }
62
+ active_skills(user_id).size
60
63
  end
61
64
 
62
65
  def delete_skills(user_id, ids)
@@ -68,6 +71,24 @@ module Llmemory
68
71
  end
69
72
  end
70
73
 
74
+ def archive_skills(user_id, ids)
75
+ Array(ids).map(&:to_s).count do |id|
76
+ path = skill_path(user_id, id)
77
+ next false unless File.file?(path)
78
+ data = load_skill_raw(path)
79
+ next false if data["archived_at"]
80
+ data["archived_at"] = Time.now.iso8601
81
+ write_skill_file(path, data)
82
+ true
83
+ end
84
+ end
85
+
86
+ def expired_skill_ids(user_id, cutoff:)
87
+ active_skills(user_id)
88
+ .select { |s| (s[:created_at] || Time.now) < cutoff }
89
+ .map { |s| s[:id].to_s }
90
+ end
91
+
71
92
  def list_users
72
93
  return [] unless Dir.exist?(@base_path)
73
94
  Dir.children(@base_path).select { |d| Dir.exist?(File.join(@base_path, d, "skills")) }
@@ -75,6 +96,10 @@ module Llmemory
75
96
 
76
97
  private
77
98
 
99
+ def active_skills(user_id)
100
+ all_skills(user_id).reject { |s| s[:archived_at] }
101
+ end
102
+
78
103
  def all_skills(user_id)
79
104
  dir = user_path(user_id, "skills")
80
105
  return [] unless Dir.exist?(dir)
@@ -82,7 +107,9 @@ module Llmemory
82
107
  end
83
108
 
84
109
  def load_skill(path)
85
- data = JSON.parse(File.read(path), symbolize_names: true)
110
+ data = load_skill_raw(path)
111
+ return nil unless data
112
+
86
113
  data[:created_at] = parse_time(data[:created_at])
87
114
  data[:updated_at] = parse_time(data[:updated_at]) if data[:updated_at]
88
115
  data
@@ -90,6 +117,17 @@ module Llmemory
90
117
  nil
91
118
  end
92
119
 
120
+ def load_skill_raw(path)
121
+ raw = File.read(path)
122
+ json = cipher.enabled? && cipher.encrypted?(raw) ? cipher.decrypt(raw) : raw
123
+ JSON.parse(json, symbolize_names: true)
124
+ end
125
+
126
+ def write_skill_file(path, data)
127
+ payload = JSON.generate(data)
128
+ File.write(path, cipher.enabled? ? cipher.encrypt(payload) : payload)
129
+ end
130
+
93
131
  def skill_text(skill)
94
132
  [skill[:name], skill[:description], skill[:body]].compact.join("\n")
95
133
  end
@@ -25,18 +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
35
  return list_skills(user_id) if query.to_s.strip.empty?
35
- @skills[user_id].select { |s| Llmemory::Tokenizer.matches?(skill_text(s), query) }
36
+ active_skills(user_id).select { |s| Llmemory::Tokenizer.matches?(skill_text(s), query) }
36
37
  end
37
38
 
38
39
  def find_skills_by_name(user_id, name)
39
- @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 }
40
41
  end
41
42
 
42
43
  def record_outcome(user_id, skill_id, success:)
@@ -49,7 +50,7 @@ module Llmemory
49
50
  end
50
51
 
51
52
  def count_skills(user_id)
52
- @skills[user_id].size
53
+ active_skills(user_id).size
53
54
  end
54
55
 
55
56
  def delete_skills(user_id, ids)
@@ -59,12 +60,42 @@ module Llmemory
59
60
  before - @skills[user_id].size
60
61
  end
61
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
+
62
81
  def list_users
63
82
  @skills.keys
64
83
  end
65
84
 
66
85
  private
67
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
+
68
99
  def symbolize(hash)
69
100
  hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
70
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