llmemory 0.2.1 → 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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +47 -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 +7 -1
  23. data/lib/llmemory/instrumentation.rb +33 -0
  24. data/lib/llmemory/llm/anthropic.rb +19 -15
  25. data/lib/llmemory/llm/openai.rb +16 -12
  26. data/lib/llmemory/long_term/episodic/memory.rb +23 -10
  27. data/lib/llmemory/long_term/episodic/storages/active_record_storage.rb +14 -4
  28. data/lib/llmemory/long_term/episodic/storages/base.rb +15 -2
  29. data/lib/llmemory/long_term/episodic/storages/database_storage.rb +26 -5
  30. data/lib/llmemory/long_term/episodic/storages/file_storage.rb +27 -6
  31. data/lib/llmemory/long_term/episodic/storages/memory_storage.rb +35 -4
  32. data/lib/llmemory/long_term/file_based/memory.rb +12 -4
  33. data/lib/llmemory/long_term/file_based/storages/active_record_storage.rb +4 -2
  34. data/lib/llmemory/long_term/file_based/storages/base.rb +2 -2
  35. data/lib/llmemory/long_term/file_based/storages/database_storage.rb +4 -2
  36. data/lib/llmemory/long_term/file_based/storages/file_storage.rb +4 -2
  37. data/lib/llmemory/long_term/file_based/storages/memory_storage.rb +4 -2
  38. data/lib/llmemory/long_term/graph_based/memory.rb +12 -4
  39. data/lib/llmemory/long_term/graph_based/storages/active_record_storage.rb +4 -2
  40. data/lib/llmemory/long_term/graph_based/storages/base.rb +2 -2
  41. data/lib/llmemory/long_term/graph_based/storages/memory_storage.rb +4 -2
  42. data/lib/llmemory/long_term/procedural/memory.rb +26 -13
  43. data/lib/llmemory/long_term/procedural/skill.rb +6 -2
  44. data/lib/llmemory/long_term/procedural/storages/active_record_storage.rb +15 -5
  45. data/lib/llmemory/long_term/procedural/storages/base.rb +14 -1
  46. data/lib/llmemory/long_term/procedural/storages/database_storage.rb +27 -6
  47. data/lib/llmemory/long_term/procedural/storages/file_storage.rb +28 -7
  48. data/lib/llmemory/long_term/procedural/storages/memory_storage.rb +36 -5
  49. data/lib/llmemory/maintenance/cognitive_pass.rb +109 -0
  50. data/lib/llmemory/maintenance/ttl_expiry.rb +50 -0
  51. data/lib/llmemory/maintenance.rb +2 -0
  52. data/lib/llmemory/mcp/server.rb +5 -1
  53. data/lib/llmemory/mcp/tools/memory_maintain.rb +53 -0
  54. data/lib/llmemory/mcp/tools/memory_mine_skills.rb +53 -0
  55. data/lib/llmemory/memory.rb +20 -0
  56. data/lib/llmemory/memory_module.rb +13 -6
  57. data/lib/llmemory/reflection/reflector.rb +24 -20
  58. data/lib/llmemory/retrieval/engine.rb +25 -16
  59. data/lib/llmemory/skill_mining/miner.rb +163 -0
  60. data/lib/llmemory/skill_mining.rb +8 -0
  61. data/lib/llmemory/vector_store/openai_embeddings.rb +11 -7
  62. data/lib/llmemory/version.rb +1 -1
  63. data/lib/llmemory.rb +2 -0
  64. metadata +22 -1
@@ -123,17 +123,19 @@ module Llmemory
123
123
  LlmemoryCategory.distinct.pluck(:user_id)).uniq
124
124
  end
125
125
 
126
- def list_resources(user_id:, limit: nil)
126
+ def list_resources(user_id:, limit: nil, offset: nil)
127
127
  scope = LlmemoryResource.where(user_id: user_id).order(:created_at)
128
128
  scope = scope.limit(limit) if limit && limit.to_i.positive?
129
+ scope = scope.offset(offset) if offset && offset.to_i.positive?
129
130
  scope.map { |r| row_to_resource(r) }
130
131
  end
131
132
 
132
- def list_items(user_id:, category: nil, limit: nil)
133
+ def list_items(user_id:, category: nil, limit: nil, offset: nil)
133
134
  scope = LlmemoryItem.where(user_id: user_id)
134
135
  scope = scope.where(category: category) if category
135
136
  scope = scope.order(:created_at)
136
137
  scope = scope.limit(limit) if limit && limit.to_i.positive?
138
+ scope = scope.offset(offset) if offset && offset.to_i.positive?
137
139
  scope.map { |r| row_to_item(r) }
138
140
  end
139
141
 
@@ -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
 
@@ -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
@@ -152,14 +152,16 @@ module Llmemory
152
152
  Dir.children(@base_path).select { |e| File.directory?(File.join(@base_path, e)) && !e.start_with?(".") }
153
153
  end
154
154
 
155
- def list_resources(user_id:, limit: nil)
155
+ def list_resources(user_id:, limit: nil, offset: nil)
156
156
  list = get_all_resources(user_id)
157
+ list = list.drop(offset.to_i) if offset && offset.to_i.positive?
157
158
  limit ? list.take(limit) : list
158
159
  end
159
160
 
160
- def list_items(user_id:, category: nil, limit: nil)
161
+ def list_items(user_id:, category: nil, limit: nil, offset: nil)
161
162
  list = get_all_items(user_id)
162
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?
163
165
  list = list.take(limit) if limit
164
166
  list
165
167
  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
@@ -78,11 +78,15 @@ module Llmemory
78
78
  # --- MemoryModule uniform interface ---
79
79
 
80
80
  def write(payload, **_meta)
81
- memorize(payload)
81
+ result = nil
82
+ Llmemory::Instrumentation.instrument(:memory_write, memory_type: "graph_based", user_id: @user_id) do
83
+ result = memorize(payload)
84
+ end
85
+ result
82
86
  end
83
87
 
84
- def list(user_id: nil, limit: nil)
85
- @graph_storage.list_nodes(user_id || @user_id, limit: limit)
88
+ def list(user_id: nil, limit: nil, offset: nil)
89
+ @graph_storage.list_nodes(user_id || @user_id, limit: limit, offset: offset)
86
90
  end
87
91
 
88
92
  def stats(user_id: nil)
@@ -95,9 +99,13 @@ module Llmemory
95
99
  # removal in the audit log. Edges are soft-archived (archived_at) so they
96
100
  # no longer appear in retrieval; nodes are left in place (a node may still
97
101
  # be referenced by other active edges). Returns the number archived.
98
- def forget(ids:, reason: nil)
102
+ def forget(ids:, reason: nil, mode: :soft)
103
+ # `:hard` would physically delete edge rows; not yet wired (the graph
104
+ # store only exposes soft archive_edge). Both modes route to archive
105
+ # for now; behavior is the same — kept for API uniformity.
99
106
  archived = Array(ids).map(&:to_s).select { |edge_id| @kg.archive_edge(edge_id) }
100
107
  forget_log.record(@user_id, memory_type: "graph_based", ids: archived, reason: reason)
108
+ Llmemory::Instrumentation.instrument(:memory_forget, memory_type: "graph_based", user_id: @user_id, count: archived.size, mode: mode)
101
109
  archived.size
102
110
  end
103
111
 
@@ -52,10 +52,11 @@ module Llmemory
52
52
  record_to_node(rec) if rec
53
53
  end
54
54
 
55
- def list_nodes(user_id, entity_type: nil, limit: nil)
55
+ def list_nodes(user_id, entity_type: nil, limit: nil, offset: nil)
56
56
  scope = LlmemoryGraphNode.where(user_id: user_id)
57
57
  scope = scope.where(entity_type: entity_type) if entity_type
58
58
  scope = scope.limit(limit) if limit && limit.to_i.positive?
59
+ scope = scope.offset(offset) if offset && offset.to_i.positive?
59
60
  scope.map { |r| record_to_node(r) }
60
61
  end
61
62
 
@@ -106,12 +107,13 @@ module Llmemory
106
107
  (LlmemoryGraphNode.distinct.pluck(:user_id) + LlmemoryGraphEdge.distinct.pluck(:user_id)).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
  scope = LlmemoryGraphEdge.where(user_id: user_id, archived_at: nil)
111
112
  scope = scope.where(subject_id: subject_id) if subject_id
112
113
  scope = scope.where(predicate: predicate) if predicate
113
114
  scope = scope.order(created_at: :desc) if limit && limit.to_i.positive?
114
115
  scope = scope.limit(limit) if limit && limit.to_i.positive?
116
+ scope = scope.offset(offset) if offset && offset.to_i.positive?
115
117
  scope.map { |r| record_to_edge(r) }
116
118
  end
117
119
 
@@ -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
 
@@ -31,11 +31,11 @@ module Llmemory
31
31
 
32
32
  # Registers a skill. If `version` is omitted and a skill with the same
33
33
  # name exists, the version auto-increments (skill evolution).
34
- def register_skill(name:, body:, description: nil, kind: Skill::DEFAULT_KIND, version: nil)
34
+ def register_skill(name:, body:, description: nil, kind: Skill::DEFAULT_KIND, version: nil, provenance: nil)
35
35
  version ||= next_version_for(name)
36
36
  skill = Skill.new(
37
37
  id: nil, user_id: @user_id, name: name, body: body,
38
- description: description, kind: kind, version: version
38
+ description: description, kind: kind, version: version, provenance: provenance
39
39
  )
40
40
  id = @storage.save_skill(@user_id, skill.to_h)
41
41
  index_vector(id, skill.searchable_text)
@@ -52,8 +52,8 @@ module Llmemory
52
52
  raw && Skill.from_h(raw)
53
53
  end
54
54
 
55
- def skills(limit: nil)
56
- @storage.list_skills(@user_id, limit: limit).map { |s| Skill.from_h(s) }
55
+ def skills(limit: nil, offset: nil)
56
+ @storage.list_skills(@user_id, limit: limit, offset: offset).map { |s| Skill.from_h(s) }
57
57
  end
58
58
 
59
59
  def count
@@ -83,25 +83,38 @@ module Llmemory
83
83
 
84
84
  # --- MemoryModule uniform interface ---
85
85
 
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)
86
+ def write(name:, body:, description: nil, kind: Skill::DEFAULT_KIND, version: nil, provenance: nil, **_meta)
87
+ result = nil
88
+ Llmemory::Instrumentation.instrument(:memory_write, memory_type: "procedural", user_id: @user_id) do
89
+ result = register_skill(name: name, body: body, description: description, kind: kind, version: version, provenance: provenance)
90
+ end
91
+ result
88
92
  end
89
93
 
90
- def list(user_id: nil, limit: nil)
91
- skills(limit: limit)
94
+ def list(user_id: nil, limit: nil, offset: nil)
95
+ skills(limit: limit, offset: offset)
92
96
  end
93
97
 
94
98
  def stats(user_id: nil)
95
99
  { skills: count }
96
100
  end
97
101
 
98
- def forget(ids:, reason: nil)
102
+ def forget(ids:, reason: nil, mode: :soft)
99
103
  requested = Array(ids).map(&:to_s)
100
104
  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
105
+ targeted = requested & existing
106
+ count = case mode
107
+ when :hard then @storage.delete_skills(@user_id, targeted).to_i
108
+ else @storage.archive_skills(@user_id, targeted).to_i
109
+ end
110
+ forget_log.record(@user_id, memory_type: "procedural", ids: targeted, reason: reason)
111
+ Llmemory::Instrumentation.instrument(:memory_forget, memory_type: "procedural", user_id: @user_id, count: count, mode: mode)
112
+ count
113
+ end
114
+
115
+ # Storage accessor for the TTL maintenance job.
116
+ def expired_ids(cutoff:)
117
+ @storage.expired_skill_ids(@user_id, cutoff: cutoff)
105
118
  end
106
119
 
107
120
  private
@@ -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
  }
@@ -41,19 +41,20 @@ module Llmemory
41
41
  LlmemorySkill.find_by(user_id: user_id, id: id)&.data
42
42
  end
43
43
 
44
- def list_skills(user_id, limit: nil)
45
- scope = LlmemorySkill.where(user_id: user_id).order(created_at: :desc)
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
46
  scope = scope.limit(limit) if limit && limit.to_i.positive?
47
+ scope = scope.offset(offset) if offset && offset.to_i.positive?
47
48
  scope.map(&:data)
48
49
  end
49
50
 
50
51
  def search_skills(user_id, query)
51
- token_scope(LlmemorySkill.where(user_id: user_id), "search_text", query)
52
+ token_scope(LlmemorySkill.where(user_id: user_id, archived_at: nil), "search_text", query)
52
53
  .order(created_at: :desc).map(&:data)
53
54
  end
54
55
 
55
56
  def find_skills_by_name(user_id, name)
56
- LlmemorySkill.where(user_id: user_id).where("data->>'name' = ?", name.to_s).map(&:data)
57
+ LlmemorySkill.where(user_id: user_id, archived_at: nil).where("data->>'name' = ?", name.to_s).map(&:data)
57
58
  end
58
59
 
59
60
  def record_outcome(user_id, skill_id, success:)
@@ -70,13 +71,22 @@ module Llmemory
70
71
  end
71
72
 
72
73
  def count_skills(user_id)
73
- LlmemorySkill.where(user_id: user_id).count
74
+ LlmemorySkill.where(user_id: user_id, archived_at: nil).count
74
75
  end
75
76
 
76
77
  def delete_skills(user_id, ids)
77
78
  LlmemorySkill.where(user_id: user_id, id: Array(ids).map(&:to_s)).delete_all
78
79
  end
79
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
+
80
90
  def list_users
81
91
  LlmemorySkill.distinct.pluck(:user_id)
82
92
  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
@@ -38,10 +38,11 @@ module Llmemory
38
38
  rows.any? ? parse_data(rows.first["data"]) : nil
39
39
  end
40
40
 
41
- def list_skills(user_id, limit: nil)
41
+ def list_skills(user_id, limit: nil, offset: nil)
42
42
  ensure_tables!
43
- sql = "SELECT data FROM llmemory_skills WHERE user_id = $1 ORDER BY created_at DESC"
43
+ sql = "SELECT data FROM llmemory_skills WHERE user_id = $1 AND archived_at IS NULL ORDER BY created_at DESC"
44
44
  sql += " LIMIT #{limit.to_i}" if limit && limit.to_i.positive?
45
+ sql += " OFFSET #{offset.to_i}" if offset && offset.to_i.positive?
45
46
  conn.exec_params(sql, [user_id]).map { |r| parse_data(r["data"]) }
46
47
  end
47
48
 
@@ -49,7 +50,7 @@ module Llmemory
49
50
  ensure_tables!
50
51
  suffix, params = token_filter("search_text", query, 2)
51
52
  conn.exec_params(
52
- "SELECT data FROM llmemory_skills WHERE user_id = $1#{suffix} ORDER BY created_at DESC",
53
+ "SELECT data FROM llmemory_skills WHERE user_id = $1 AND archived_at IS NULL#{suffix} ORDER BY created_at DESC",
53
54
  [user_id, *params]
54
55
  ).map { |r| parse_data(r["data"]) }
55
56
  end
@@ -57,7 +58,7 @@ module Llmemory
57
58
  def find_skills_by_name(user_id, name)
58
59
  ensure_tables!
59
60
  conn.exec_params(
60
- "SELECT data FROM llmemory_skills WHERE user_id = $1 AND data->>'name' = $2",
61
+ "SELECT data FROM llmemory_skills WHERE user_id = $1 AND archived_at IS NULL AND data->>'name' = $2",
61
62
  [user_id, name.to_s]
62
63
  ).map { |r| parse_data(r["data"]) }
63
64
  end
@@ -78,7 +79,7 @@ module Llmemory
78
79
 
79
80
  def count_skills(user_id)
80
81
  ensure_tables!
81
- conn.exec_params("SELECT COUNT(*) AS c FROM llmemory_skills WHERE user_id = $1", [user_id]).first["c"].to_i
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
82
83
  end
83
84
 
84
85
  def delete_skills(user_id, ids)
@@ -88,6 +89,24 @@ module Llmemory
88
89
  end
89
90
  end
90
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
+
91
110
  def list_users
92
111
  ensure_tables!
93
112
  conn.exec("SELECT DISTINCT user_id FROM llmemory_skills").map { |r| r["user_id"] }
@@ -109,9 +128,11 @@ module Llmemory
109
128
  user_id TEXT NOT NULL,
110
129
  data JSONB NOT NULL DEFAULT '{}'::jsonb,
111
130
  search_text TEXT,
112
- created_at TIMESTAMPTZ NOT NULL
131
+ created_at TIMESTAMPTZ NOT NULL,
132
+ archived_at TIMESTAMPTZ
113
133
  );
114
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;
115
136
  SQL
116
137
  end
117
138
 
@@ -29,18 +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
39
  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) }
40
+ active_skills(user_id).select { |s| Llmemory::Tokenizer.matches?(skill_text(s), query) }
40
41
  end
41
42
 
42
43
  def find_skills_by_name(user_id, name)
43
- 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 }
44
45
  end
45
46
 
46
47
  def record_outcome(user_id, skill_id, success:)
@@ -54,9 +55,7 @@ module Llmemory
54
55
  end
55
56
 
56
57
  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") }
58
+ active_skills(user_id).size
60
59
  end
61
60
 
62
61
  def delete_skills(user_id, ids)
@@ -68,6 +67,24 @@ module Llmemory
68
67
  end
69
68
  end
70
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
+
71
88
  def list_users
72
89
  return [] unless Dir.exist?(@base_path)
73
90
  Dir.children(@base_path).select { |d| Dir.exist?(File.join(@base_path, d, "skills")) }
@@ -75,6 +92,10 @@ module Llmemory
75
92
 
76
93
  private
77
94
 
95
+ def active_skills(user_id)
96
+ all_skills(user_id).reject { |s| s[:archived_at] }
97
+ end
98
+
78
99
  def all_skills(user_id)
79
100
  dir = user_path(user_id, "skills")
80
101
  return [] unless Dir.exist?(dir)
@@ -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