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
@@ -3,15 +3,19 @@
3
3
  require "json"
4
4
  require "securerandom"
5
5
  require_relative "base"
6
+ require_relative "../../../crypto/field_helpers"
6
7
 
7
8
  module Llmemory
8
9
  module LongTerm
9
10
  module FileBased
10
11
  module Storages
11
12
  class DatabaseStorage < Base
12
- def initialize(database_url: nil)
13
+ include Llmemory::Crypto::FieldHelpers
14
+
15
+ def initialize(database_url: nil, cipher: nil)
13
16
  @database_url = database_url || Llmemory.configuration.database_url
14
17
  @connection = nil
18
+ @cipher = cipher || Llmemory.build_cipher
15
19
  end
16
20
 
17
21
  def save_resource(user_id, text)
@@ -19,7 +23,7 @@ module Llmemory
19
23
  id = "res_#{SecureRandom.hex(8)}"
20
24
  conn.exec_params(
21
25
  "INSERT INTO llmemory_resources (id, user_id, text, created_at) VALUES ($1, $2, $3, $4)",
22
- [id, user_id, text, Time.now.utc.iso8601]
26
+ [id, user_id, enc(text), Time.now.utc.iso8601]
23
27
  )
24
28
  id
25
29
  end
@@ -29,7 +33,7 @@ module Llmemory
29
33
  id = "item_#{SecureRandom.hex(8)}"
30
34
  conn.exec_params(
31
35
  "INSERT INTO llmemory_items (id, user_id, category, content, source_resource_id, importance, provenance, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8)",
32
- [id, user_id, category, content, source_resource_id, importance.to_f, provenance ? JSON.generate(provenance) : nil, Time.now.utc.iso8601]
36
+ [id, user_id, category, enc(content), source_resource_id, importance.to_f, provenance_json(provenance), Time.now.utc.iso8601]
33
37
  )
34
38
  id
35
39
  end
@@ -40,7 +44,7 @@ module Llmemory
40
44
  "SELECT content FROM llmemory_categories WHERE user_id = $1 AND category_name = $2",
41
45
  [user_id, category_name]
42
46
  )
43
- result.any? ? result.first["content"].to_s : ""
47
+ result.any? ? dec(result.first["content"].to_s) : ""
44
48
  end
45
49
 
46
50
  def save_category(user_id, category_name, content)
@@ -52,7 +56,7 @@ module Llmemory
52
56
  ON CONFLICT (user_id, category_name)
53
57
  DO UPDATE SET content = $3, updated_at = $4
54
58
  SQL
55
- [user_id, category_name, content, Time.now.utc.iso8601]
59
+ [user_id, category_name, enc(content), Time.now.utc.iso8601]
56
60
  )
57
61
  true
58
62
  end
@@ -145,7 +149,7 @@ module Llmemory
145
149
  id,
146
150
  user_id,
147
151
  merged_item[:category],
148
- merged_item[:content],
152
+ enc(merged_item[:content]),
149
153
  merged_item[:source_resource_id],
150
154
  created_at
151
155
  ]
@@ -169,15 +173,16 @@ module Llmemory
169
173
  conn.exec("SELECT DISTINCT user_id FROM llmemory_categories").map { |r| r["user_id"] }).uniq
170
174
  end
171
175
 
172
- def list_resources(user_id:, limit: nil)
176
+ def list_resources(user_id:, limit: nil, offset: nil)
173
177
  ensure_tables!
174
178
  sql = "SELECT id, text, created_at FROM llmemory_resources WHERE user_id = $1 ORDER BY created_at"
175
179
  sql += " LIMIT #{limit.to_i}" if limit && limit.to_i.positive?
180
+ sql += " OFFSET #{offset.to_i}" if offset && offset.to_i.positive?
176
181
  rows = conn.exec_params(sql, [user_id])
177
182
  rows_to_resources(rows)
178
183
  end
179
184
 
180
- def list_items(user_id:, category: nil, limit: nil)
185
+ def list_items(user_id:, category: nil, limit: nil, offset: nil)
181
186
  ensure_tables!
182
187
  sql = "SELECT id, category, content, source_resource_id, importance, provenance, created_at FROM llmemory_items WHERE user_id = $1"
183
188
  params = [user_id]
@@ -187,6 +192,7 @@ module Llmemory
187
192
  end
188
193
  sql += " ORDER BY created_at"
189
194
  sql += " LIMIT #{limit.to_i}" if limit && limit.to_i.positive?
195
+ sql += " OFFSET #{offset.to_i}" if offset && offset.to_i.positive?
190
196
  rows = params.size == 1 ? conn.exec_params(sql, params) : conn.exec_params(sql, params)
191
197
  rows_to_items(rows)
192
198
  end
@@ -281,7 +287,7 @@ module Llmemory
281
287
  {
282
288
  id: r["id"],
283
289
  category: r["category"],
284
- content: r["content"],
290
+ content: dec(r["content"]),
285
291
  source_resource_id: r["source_resource_id"],
286
292
  importance: (r["importance"] || 0.7).to_f,
287
293
  provenance: parse_provenance(r["provenance"]),
@@ -302,16 +308,28 @@ module Llmemory
302
308
 
303
309
  def parse_provenance(value)
304
310
  return nil if value.nil? || value.to_s.strip.empty?
311
+ return value.transform_keys(&:to_sym) if value.is_a?(Hash)
312
+ return dec_json(value) if value.is_a?(String) && cipher.encrypted?(value)
313
+
305
314
  JSON.parse(value, symbolize_names: true)
306
315
  rescue JSON::ParserError
307
316
  nil
308
317
  end
309
318
 
319
+ def provenance_json(provenance)
320
+ return nil unless provenance
321
+ if cipher.enabled?
322
+ JSON.generate(enc_json(provenance))
323
+ else
324
+ JSON.generate(provenance)
325
+ end
326
+ end
327
+
310
328
  def rows_to_resources(rows)
311
329
  rows.map do |r|
312
330
  {
313
331
  id: r["id"],
314
- text: r["text"],
332
+ text: dec(r["text"]),
315
333
  created_at: Time.parse(r["created_at"])
316
334
  }
317
335
  end
@@ -3,15 +3,19 @@
3
3
  require "fileutils"
4
4
  require "json"
5
5
  require_relative "base"
6
+ require_relative "../../../crypto/field_helpers"
6
7
 
7
8
  module Llmemory
8
9
  module LongTerm
9
10
  module FileBased
10
11
  module Storages
11
12
  class FileStorage < Base
12
- def initialize(base_path: nil)
13
+ include Llmemory::Crypto::FieldHelpers
14
+
15
+ def initialize(base_path: nil, cipher: nil)
13
16
  @base_path = base_path || Llmemory.configuration.long_term_storage_path || "./llmemory_data"
14
17
  @base_path = File.expand_path(@base_path)
18
+ @cipher = cipher || Llmemory.build_cipher
15
19
  end
16
20
 
17
21
  def save_resource(user_id, text)
@@ -19,8 +23,8 @@ module Llmemory
19
23
  seq = next_seq(user_id, "resource_id_seq")
20
24
  id = "res_#{seq}"
21
25
  path = resource_path(user_id, id)
22
- data = { text: text, created_at: Time.now.iso8601 }
23
- File.write(path, JSON.generate(data))
26
+ data = { text: enc(text), created_at: Time.now.iso8601 }
27
+ write_encrypted_file(path, data)
24
28
  id
25
29
  end
26
30
 
@@ -32,26 +36,26 @@ module Llmemory
32
36
  data = {
33
37
  id: id,
34
38
  category: category,
35
- content: content,
39
+ content: enc(content),
36
40
  source_resource_id: source_resource_id,
37
41
  importance: importance,
38
- provenance: provenance,
42
+ provenance: provenance ? enc_json(provenance) : nil,
39
43
  created_at: Time.now.iso8601
40
44
  }
41
- File.write(path, JSON.generate(data))
45
+ write_encrypted_file(path, data)
42
46
  id
43
47
  end
44
48
 
45
49
  def load_category(user_id, category_name)
46
50
  path = category_path(user_id, category_name)
47
51
  return "" unless File.file?(path)
48
- File.read(path)
52
+ read_encrypted_text_file(path)
49
53
  end
50
54
 
51
55
  def save_category(user_id, category_name, content)
52
56
  ensure_user_dir(user_id, "categories")
53
57
  path = category_path(user_id, category_name)
54
- File.write(path, content)
58
+ write_encrypted_text_file(path, content)
55
59
  true
56
60
  end
57
61
 
@@ -83,7 +87,8 @@ module Llmemory
83
87
  dir = user_path(user_id, "items")
84
88
  return [] unless Dir.exist?(dir)
85
89
  Dir.children(dir).select { |f| f.end_with?(".json") }.map do |f|
86
- data = JSON.parse(File.read(File.join(dir, f)), symbolize_names: true)
90
+ data = read_encrypted_file(File.join(dir, f))
91
+ data = decrypt_item(data)
87
92
  data[:created_at] = parse_time(data[:created_at])
88
93
  data
89
94
  end.sort_by { |i| i[:created_at] }
@@ -93,9 +98,10 @@ module Llmemory
93
98
  dir = user_path(user_id, "resources")
94
99
  return [] unless Dir.exist?(dir)
95
100
  Dir.children(dir).select { |f| f.end_with?(".json") }.map do |f|
96
- data = JSON.parse(File.read(File.join(dir, f)), symbolize_names: true)
101
+ data = read_encrypted_file(File.join(dir, f))
97
102
  id = File.basename(f, ".json")
98
103
  data[:id] = id
104
+ data[:text] = dec(data[:text] || data["text"])
99
105
  data[:created_at] = parse_time(data[:created_at])
100
106
  data
101
107
  end.sort_by { |r| r[:created_at] }
@@ -113,7 +119,9 @@ module Llmemory
113
119
  id = "item_#{seq}"
114
120
  path = item_path(user_id, id)
115
121
  data = merged_item.merge(id: id).transform_values { |v| v.respond_to?(:iso8601) ? v.iso8601 : v }
116
- File.write(path, JSON.generate(data))
122
+ data[:content] = enc(data[:content]) if data[:content]
123
+ data[:provenance] = enc_json(data[:provenance]) if data[:provenance]
124
+ write_encrypted_file(path, data)
117
125
  end
118
126
 
119
127
  def archive_items(user_id, item_ids)
@@ -127,9 +135,8 @@ module Llmemory
127
135
  def save_daily_log_entry(user_id, date, content)
128
136
  ensure_user_dir(user_id, "memory")
129
137
  path = daily_log_path(user_id, date)
130
- existing = File.file?(path) ? File.read(path) : ""
131
138
  entry = "#{Time.now.strftime('%H:%M')} #{content}\n"
132
- File.write(path, existing + entry)
139
+ write_encrypted_text_file(path, entry, append: File.file?(path))
133
140
  true
134
141
  end
135
142
 
@@ -143,7 +150,7 @@ module Llmemory
143
150
  path = daily_log_path(user_id, d)
144
151
  next unless File.file?(path)
145
152
 
146
- { date: d, content: File.read(path) }
153
+ { date: d, content: read_encrypted_text_file(path) }
147
154
  end
148
155
  end
149
156
 
@@ -152,14 +159,16 @@ module Llmemory
152
159
  Dir.children(@base_path).select { |e| File.directory?(File.join(@base_path, e)) && !e.start_with?(".") }
153
160
  end
154
161
 
155
- def list_resources(user_id:, limit: nil)
162
+ def list_resources(user_id:, limit: nil, offset: nil)
156
163
  list = get_all_resources(user_id)
164
+ list = list.drop(offset.to_i) if offset && offset.to_i.positive?
157
165
  limit ? list.take(limit) : list
158
166
  end
159
167
 
160
- def list_items(user_id:, category: nil, limit: nil)
168
+ def list_items(user_id:, category: nil, limit: nil, offset: nil)
161
169
  list = get_all_items(user_id)
162
170
  list = list.select { |i| (i[:category] || i["category"]).to_s == category.to_s } if category
171
+ list = list.drop(offset.to_i) if offset && offset.to_i.positive?
163
172
  list = list.take(limit) if limit
164
173
  list
165
174
  end
@@ -222,6 +231,13 @@ module Llmemory
222
231
  return Time.parse(val.to_s) if val
223
232
  Time.now
224
233
  end
234
+
235
+ def decrypt_item(data)
236
+ data[:content] = dec(data[:content] || data["content"])
237
+ prov = data[:provenance] || data["provenance"]
238
+ data[:provenance] = parse_provenance(prov) if prov
239
+ data
240
+ end
225
241
  end
226
242
  end
227
243
  end
@@ -98,17 +98,19 @@ module Llmemory
98
98
  (@resources.keys + @items.keys + @categories.keys).uniq
99
99
  end
100
100
 
101
- def list_resources(user_id:, limit: nil)
101
+ def list_resources(user_id:, limit: nil, offset: nil)
102
102
  list = @resources[user_id].dup
103
+ list = list.drop(offset.to_i) if offset && offset.to_i.positive?
103
104
  limit ? list.take(limit) : list
104
105
  end
105
106
 
106
- def list_items(user_id:, category: nil, limit: nil)
107
+ def list_items(user_id:, category: nil, limit: nil, offset: nil)
107
108
  list = if category
108
109
  @items[user_id].select { |i| i[:category].to_s == category.to_s }
109
110
  else
110
111
  @items[user_id].dup
111
112
  end
113
+ list = list.drop(offset.to_i) if offset && offset.to_i.positive?
112
114
  list = list.take(limit) if limit
113
115
  list
114
116
  end
@@ -14,9 +14,10 @@ module Llmemory
14
14
  class Memory
15
15
  include Llmemory::MemoryModule
16
16
 
17
- def initialize(user_id:, storage: nil, vector_store: nil, llm: nil, extractor: nil)
17
+ def initialize(user_id:, storage: nil, vector_store: nil, llm: nil, extractor: nil, cipher: nil)
18
18
  @user_id = user_id
19
- @graph_storage = storage || Storages.build
19
+ @cipher = cipher || Llmemory.build_cipher
20
+ @graph_storage = storage || Storages.build(cipher: @cipher)
20
21
  @kg = KnowledgeGraph.new(user_id: user_id, storage: @graph_storage)
21
22
  @conflict_resolver = ConflictResolver.new(@kg)
22
23
  @vector_store = vector_store || build_vector_store
@@ -78,11 +79,15 @@ module Llmemory
78
79
  # --- MemoryModule uniform interface ---
79
80
 
80
81
  def write(payload, **_meta)
81
- memorize(payload)
82
+ result = nil
83
+ Llmemory::Instrumentation.instrument(:memory_write, memory_type: "graph_based", user_id: @user_id) do
84
+ result = memorize(payload)
85
+ end
86
+ result
82
87
  end
83
88
 
84
- def list(user_id: nil, limit: nil)
85
- @graph_storage.list_nodes(user_id || @user_id, limit: limit)
89
+ def list(user_id: nil, limit: nil, offset: nil)
90
+ @graph_storage.list_nodes(user_id || @user_id, limit: limit, offset: offset)
86
91
  end
87
92
 
88
93
  def stats(user_id: nil)
@@ -95,16 +100,20 @@ module Llmemory
95
100
  # removal in the audit log. Edges are soft-archived (archived_at) so they
96
101
  # no longer appear in retrieval; nodes are left in place (a node may still
97
102
  # be referenced by other active edges). Returns the number archived.
98
- def forget(ids:, reason: nil)
103
+ def forget(ids:, reason: nil, mode: :soft)
104
+ # `:hard` would physically delete edge rows; not yet wired (the graph
105
+ # store only exposes soft archive_edge). Both modes route to archive
106
+ # for now; behavior is the same — kept for API uniformity.
99
107
  archived = Array(ids).map(&:to_s).select { |edge_id| @kg.archive_edge(edge_id) }
100
108
  forget_log.record(@user_id, memory_type: "graph_based", ids: archived, reason: reason)
109
+ Llmemory::Instrumentation.instrument(:memory_forget, memory_type: "graph_based", user_id: @user_id, count: archived.size, mode: mode)
101
110
  archived.size
102
111
  end
103
112
 
104
113
  private
105
114
 
106
115
  def build_vector_store
107
- Llmemory::VectorStore.build(source_type: "edge")
116
+ Llmemory::VectorStore.build(source_type: "edge", cipher: @cipher)
108
117
  end
109
118
 
110
119
  def extract_graph(text)
@@ -7,13 +7,14 @@ module Llmemory
7
7
  module LongTerm
8
8
  module GraphBased
9
9
  module Storages
10
- def self.build(store: nil)
10
+ def self.build(store: nil, cipher: nil)
11
+ resolved_cipher = cipher || Llmemory.build_cipher
11
12
  case (store || Llmemory.configuration.long_term_store).to_s.to_sym
12
13
  when :memory
13
14
  MemoryStorage.new
14
15
  when :active_record, :activerecord
15
16
  require_relative "storages/active_record_storage"
16
- ActiveRecordStorage.new
17
+ ActiveRecordStorage.new(cipher: resolved_cipher)
17
18
  else
18
19
  MemoryStorage.new
19
20
  end
@@ -4,13 +4,17 @@ require_relative "base"
4
4
  require_relative "active_record_models"
5
5
  require_relative "../node"
6
6
  require_relative "../edge"
7
+ require_relative "../../../crypto/field_helpers"
7
8
 
8
9
  module Llmemory
9
10
  module LongTerm
10
11
  module GraphBased
11
12
  module Storages
12
13
  class ActiveRecordStorage < Base
13
- def initialize
14
+ include Llmemory::Crypto::FieldHelpers
15
+
16
+ def initialize(cipher: nil)
17
+ @cipher = cipher || Llmemory.build_cipher
14
18
  self.class.load_models!
15
19
  end
16
20
 
@@ -26,17 +30,22 @@ module Llmemory
26
30
  rec = if n.id
27
31
  LlmemoryGraphNode.find_by(user_id: user_id, id: n.id)
28
32
  else
29
- LlmemoryGraphNode.find_by(user_id: user_id, entity_type: n.entity_type, name: n.name)
33
+ LlmemoryGraphNode.find_by(
34
+ user_id: user_id,
35
+ entity_type: enc_det(n.entity_type),
36
+ name: enc_det(n.name)
37
+ )
30
38
  end
39
+ stored_props = store_properties(n.properties || {})
31
40
  if rec
32
- rec.update!(properties: n.properties || {}, updated_at: Time.current)
41
+ rec.update!(properties: stored_props, updated_at: Time.current)
33
42
  rec.id
34
43
  else
35
44
  rec = LlmemoryGraphNode.create!(
36
45
  user_id: user_id,
37
- entity_type: n.entity_type.to_s,
38
- name: n.name.to_s,
39
- properties: n.properties || {}
46
+ entity_type: enc_det(n.entity_type.to_s),
47
+ name: enc_det(n.name.to_s),
48
+ properties: stored_props
40
49
  )
41
50
  rec.id
42
51
  end
@@ -48,14 +57,19 @@ module Llmemory
48
57
  end
49
58
 
50
59
  def find_node_by_name(user_id, entity_type, name)
51
- rec = LlmemoryGraphNode.find_by(user_id: user_id, entity_type: entity_type.to_s, name: name.to_s)
60
+ rec = LlmemoryGraphNode.find_by(
61
+ user_id: user_id,
62
+ entity_type: enc_det(entity_type.to_s),
63
+ name: enc_det(name.to_s)
64
+ )
52
65
  record_to_node(rec) if rec
53
66
  end
54
67
 
55
- def list_nodes(user_id, entity_type: nil, limit: nil)
68
+ def list_nodes(user_id, entity_type: nil, limit: nil, offset: nil)
56
69
  scope = LlmemoryGraphNode.where(user_id: user_id)
57
- scope = scope.where(entity_type: entity_type) if entity_type
70
+ scope = scope.where(entity_type: enc_det(entity_type.to_s)) if entity_type
58
71
  scope = scope.limit(limit) if limit && limit.to_i.positive?
72
+ scope = scope.offset(offset) if offset && offset.to_i.positive?
59
73
  scope.map { |r| record_to_node(r) }
60
74
  end
61
75
 
@@ -63,24 +77,23 @@ module Llmemory
63
77
  e = edge.is_a?(Edge) ? edge : Edge.from_h(edge.to_h)
64
78
  rec = if e.id && e.id.is_a?(Integer)
65
79
  LlmemoryGraphEdge.find_by(user_id: user_id, id: e.id)
66
- else
67
- nil
68
80
  end
81
+ stored_props = store_properties(e.properties || {})
69
82
  if rec
70
83
  rec.update!(
71
84
  subject_id: e.subject_id,
72
- predicate: e.predicate,
85
+ predicate: enc_det(e.predicate),
73
86
  object_id: e.target_id,
74
- properties: e.properties || {}
87
+ properties: stored_props
75
88
  )
76
89
  rec.id
77
90
  else
78
91
  rec = LlmemoryGraphEdge.create!(
79
92
  user_id: user_id,
80
93
  subject_id: e.subject_id,
81
- predicate: e.predicate,
94
+ predicate: enc_det(e.predicate),
82
95
  object_id: e.target_id,
83
- properties: e.properties || {}
96
+ properties: stored_props
84
97
  )
85
98
  rec.id
86
99
  end
@@ -90,7 +103,7 @@ module Llmemory
90
103
  scope = LlmemoryGraphEdge.where(user_id: user_id)
91
104
  scope = scope.where(archived_at: nil) unless include_archived
92
105
  scope = scope.where(subject_id: subject_id) if subject_id
93
- scope = scope.where(predicate: predicate) if predicate
106
+ scope = scope.where(predicate: enc_det(predicate.to_s)) if predicate
94
107
  scope = scope.where(object_id: object_id) if object_id
95
108
  scope.map { |r| record_to_edge(r) }
96
109
  end
@@ -106,12 +119,13 @@ module Llmemory
106
119
  (LlmemoryGraphNode.distinct.pluck(:user_id) + LlmemoryGraphEdge.distinct.pluck(:user_id)).uniq
107
120
  end
108
121
 
109
- def list_edges(user_id, subject_id: nil, predicate: nil, limit: nil)
122
+ def list_edges(user_id, subject_id: nil, predicate: nil, limit: nil, offset: nil)
110
123
  scope = LlmemoryGraphEdge.where(user_id: user_id, archived_at: nil)
111
124
  scope = scope.where(subject_id: subject_id) if subject_id
112
- scope = scope.where(predicate: predicate) if predicate
125
+ scope = scope.where(predicate: enc_det(predicate.to_s)) if predicate
113
126
  scope = scope.order(created_at: :desc) if limit && limit.to_i.positive?
114
127
  scope = scope.limit(limit) if limit && limit.to_i.positive?
128
+ scope = scope.offset(offset) if offset && offset.to_i.positive?
115
129
  scope.map { |r| record_to_edge(r) }
116
130
  end
117
131
 
@@ -151,13 +165,27 @@ module Llmemory
151
165
 
152
166
  private
153
167
 
168
+ def store_properties(props)
169
+ return props || {} unless cipher.enabled?
170
+ return {} if props.nil? || props.empty?
171
+
172
+ enc_json(props)
173
+ end
174
+
175
+ def load_properties(raw)
176
+ return raw || {} if raw.nil? || raw == {}
177
+ return dec_json(raw) if raw.is_a?(String) && cipher.encrypted?(raw)
178
+
179
+ raw
180
+ end
181
+
154
182
  def record_to_node(r)
155
183
  Node.new(
156
184
  id: r.id,
157
185
  user_id: r.user_id,
158
- entity_type: r.entity_type,
159
- name: r.name,
160
- properties: r.properties || {},
186
+ entity_type: dec(r.entity_type),
187
+ name: dec(r.name),
188
+ properties: load_properties(r.properties),
161
189
  created_at: r.created_at,
162
190
  updated_at: r.updated_at
163
191
  )
@@ -168,9 +196,9 @@ module Llmemory
168
196
  id: r.id,
169
197
  user_id: r.user_id,
170
198
  subject_id: r.subject_id,
171
- predicate: r.predicate,
199
+ predicate: dec(r.predicate),
172
200
  target_id: r.object_id,
173
- properties: r.properties || {},
201
+ properties: load_properties(r.properties),
174
202
  created_at: r.created_at,
175
203
  archived_at: r.archived_at
176
204
  )
@@ -17,7 +17,7 @@ module Llmemory
17
17
  raise NotImplementedError, "#{self.class}#find_node_by_name must be implemented"
18
18
  end
19
19
 
20
- def list_nodes(user_id, entity_type: nil, limit: nil)
20
+ def list_nodes(user_id, entity_type: nil, limit: nil, offset: nil)
21
21
  raise NotImplementedError, "#{self.class}#list_nodes must be implemented"
22
22
  end
23
23
 
@@ -37,7 +37,7 @@ module Llmemory
37
37
  raise NotImplementedError, "#{self.class}#list_users must be implemented"
38
38
  end
39
39
 
40
- def list_edges(user_id, subject_id: nil, predicate: nil, limit: nil)
40
+ def list_edges(user_id, subject_id: nil, predicate: nil, limit: nil, offset: nil)
41
41
  raise NotImplementedError, "#{self.class}#list_edges must be implemented"
42
42
  end
43
43
 
@@ -43,9 +43,10 @@ module Llmemory
43
43
  @nodes[user_id].values.find { |n| n.entity_type == entity_type.to_s && n.name.to_s == name.to_s }
44
44
  end
45
45
 
46
- def list_nodes(user_id, entity_type: nil, limit: nil)
46
+ def list_nodes(user_id, entity_type: nil, limit: nil, offset: nil)
47
47
  list = @nodes[user_id].values
48
48
  list = list.select { |n| n.entity_type.to_s == entity_type.to_s } if entity_type
49
+ list = list.drop(offset.to_i) if offset && offset.to_i.positive?
49
50
  limit ? list.take(limit) : list
50
51
  end
51
52
 
@@ -106,8 +107,9 @@ module Llmemory
106
107
  (@nodes.keys + @edges.keys).uniq
107
108
  end
108
109
 
109
- def list_edges(user_id, subject_id: nil, predicate: nil, limit: nil)
110
+ def list_edges(user_id, subject_id: nil, predicate: nil, limit: nil, offset: nil)
110
111
  list = find_edges(user_id, subject_id: subject_id, predicate: predicate, object_id: nil, include_archived: false)
112
+ list = list.drop(offset.to_i) if offset && offset.to_i.positive?
111
113
  limit ? list.take(limit) : list
112
114
  end
113
115
 
@@ -22,20 +22,21 @@ module Llmemory
22
22
 
23
23
  attr_reader :user_id, :storage
24
24
 
25
- def initialize(user_id:, storage: nil, vector_store: nil)
25
+ def initialize(user_id:, storage: nil, vector_store: nil, cipher: nil)
26
26
  @user_id = user_id
27
- @storage = storage || Storages.build
27
+ @cipher = cipher || Llmemory.build_cipher
28
+ @storage = storage || Storages.build(cipher: @cipher)
28
29
  @vector_store = vector_store
29
30
  @vector_explicit = !vector_store.nil?
30
31
  end
31
32
 
32
33
  # Registers a skill. If `version` is omitted and a skill with the same
33
34
  # name exists, the version auto-increments (skill evolution).
34
- def register_skill(name:, body:, description: nil, kind: Skill::DEFAULT_KIND, version: nil)
35
+ def register_skill(name:, body:, description: nil, kind: Skill::DEFAULT_KIND, version: nil, provenance: nil)
35
36
  version ||= next_version_for(name)
36
37
  skill = Skill.new(
37
38
  id: nil, user_id: @user_id, name: name, body: body,
38
- description: description, kind: kind, version: version
39
+ description: description, kind: kind, version: version, provenance: provenance
39
40
  )
40
41
  id = @storage.save_skill(@user_id, skill.to_h)
41
42
  index_vector(id, skill.searchable_text)
@@ -52,8 +53,8 @@ module Llmemory
52
53
  raw && Skill.from_h(raw)
53
54
  end
54
55
 
55
- def skills(limit: nil)
56
- @storage.list_skills(@user_id, limit: limit).map { |s| Skill.from_h(s) }
56
+ def skills(limit: nil, offset: nil)
57
+ @storage.list_skills(@user_id, limit: limit, offset: offset).map { |s| Skill.from_h(s) }
57
58
  end
58
59
 
59
60
  def count
@@ -83,25 +84,38 @@ module Llmemory
83
84
 
84
85
  # --- MemoryModule uniform interface ---
85
86
 
86
- def write(name:, body:, description: nil, kind: Skill::DEFAULT_KIND, version: nil, **_meta)
87
- register_skill(name: name, body: body, description: description, kind: kind, version: version)
87
+ def write(name:, body:, description: nil, kind: Skill::DEFAULT_KIND, version: nil, provenance: nil, **_meta)
88
+ result = nil
89
+ Llmemory::Instrumentation.instrument(:memory_write, memory_type: "procedural", user_id: @user_id) do
90
+ result = register_skill(name: name, body: body, description: description, kind: kind, version: version, provenance: provenance)
91
+ end
92
+ result
88
93
  end
89
94
 
90
- def list(user_id: nil, limit: nil)
91
- skills(limit: limit)
95
+ def list(user_id: nil, limit: nil, offset: nil)
96
+ skills(limit: limit, offset: offset)
92
97
  end
93
98
 
94
99
  def stats(user_id: nil)
95
100
  { skills: count }
96
101
  end
97
102
 
98
- def forget(ids:, reason: nil)
103
+ def forget(ids:, reason: nil, mode: :soft)
99
104
  requested = Array(ids).map(&:to_s)
100
105
  existing = @storage.list_skills(@user_id).map { |s| (s[:id] || s["id"]).to_s }
101
- removed = requested & existing
102
- @storage.delete_skills(@user_id, removed)
103
- forget_log.record(@user_id, memory_type: "procedural", ids: removed, reason: reason)
104
- removed.size
106
+ targeted = requested & existing
107
+ count = case mode
108
+ when :hard then @storage.delete_skills(@user_id, targeted).to_i
109
+ else @storage.archive_skills(@user_id, targeted).to_i
110
+ end
111
+ forget_log.record(@user_id, memory_type: "procedural", ids: targeted, reason: reason)
112
+ Llmemory::Instrumentation.instrument(:memory_forget, memory_type: "procedural", user_id: @user_id, count: count, mode: mode)
113
+ count
114
+ end
115
+
116
+ # Storage accessor for the TTL maintenance job.
117
+ def expired_ids(cutoff:)
118
+ @storage.expired_skill_ids(@user_id, cutoff: cutoff)
105
119
  end
106
120
 
107
121
  private
@@ -118,7 +132,7 @@ module Llmemory
118
132
  if @vector_explicit
119
133
  @vector_store
120
134
  elsif Llmemory.configuration.procedural_vector_enabled
121
- @vector_store ||= Llmemory::VectorStore.build(source_type: "skill")
135
+ @vector_store ||= Llmemory::VectorStore.build(source_type: "skill", cipher: @cipher)
122
136
  end
123
137
  end
124
138