llmemory 0.2.3 → 0.2.5

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +37 -2
  3. data/lib/generators/llmemory/install/templates/create_llmemory_tables.rb +5 -0
  4. data/lib/llmemory/cli/commands/stats.rb +5 -0
  5. data/lib/llmemory/crypto/cipher.rb +12 -0
  6. data/lib/llmemory/crypto/field_helpers.rb +25 -0
  7. data/lib/llmemory/instrumentation.rb +4 -2
  8. data/lib/llmemory/llm/anthropic.rb +8 -3
  9. data/lib/llmemory/llm/base.rb +42 -0
  10. data/lib/llmemory/llm/openai.rb +27 -12
  11. data/lib/llmemory/llm/response.rb +18 -0
  12. data/lib/llmemory/llm/tracking_client.rb +61 -0
  13. data/lib/llmemory/llm/usage.rb +31 -0
  14. data/lib/llmemory/llm/usage_ledger.rb +118 -0
  15. data/lib/llmemory/llm/usage_recorder.rb +37 -0
  16. data/lib/llmemory/llm.rb +5 -0
  17. data/lib/llmemory/long_term/episodic/memory.rb +12 -1
  18. data/lib/llmemory/long_term/episodic/storages/active_record_storage.rb +25 -7
  19. data/lib/llmemory/long_term/episodic/storages/database_storage.rb +28 -11
  20. data/lib/llmemory/long_term/file_based/storages/active_record_storage.rb +28 -34
  21. data/lib/llmemory/long_term/file_based/storages/base.rb +24 -0
  22. data/lib/llmemory/long_term/file_based/storages/database_storage.rb +50 -34
  23. data/lib/llmemory/long_term/file_based/storages/file_storage.rb +10 -2
  24. data/lib/llmemory/long_term/file_based/storages/memory_storage.rb +2 -28
  25. data/lib/llmemory/long_term/graph_based/memory.rb +13 -0
  26. data/lib/llmemory/long_term/graph_based/storage.rb +3 -1
  27. data/lib/llmemory/long_term/procedural/memory.rb +12 -1
  28. data/lib/llmemory/long_term/procedural/storages/active_record_storage.rb +40 -9
  29. data/lib/llmemory/long_term/procedural/storages/database_storage.rb +57 -17
  30. data/lib/llmemory/maintenance/search_tokens_backfill.rb +339 -0
  31. data/lib/llmemory/maintenance.rb +1 -0
  32. data/lib/llmemory/mcp/tools/memory_retrieve.rb +2 -0
  33. data/lib/llmemory/mcp/tools/memory_stats.rb +13 -0
  34. data/lib/llmemory/mcp/tools/memory_timeline.rb +48 -14
  35. data/lib/llmemory/mcp/tools/memory_timeline_context.rb +15 -5
  36. data/lib/llmemory/memory.rb +34 -15
  37. data/lib/llmemory/short_term/checkpoint.rb +2 -0
  38. data/lib/llmemory/vector_store/active_record_store.rb +6 -0
  39. data/lib/llmemory/vector_store/memory_store.rb +13 -3
  40. data/lib/llmemory/vector_store/openai_embeddings.rb +23 -2
  41. data/lib/llmemory/version.rb +1 -1
  42. data/lib/tasks/llmemory.rake +37 -0
  43. metadata +9 -2
@@ -35,7 +35,9 @@ module Llmemory
35
35
  rec = LlmemoryEpisode.find_or_initialize_by(id: id)
36
36
  rec.user_id = user_id
37
37
  rec.data = cipher.enabled? ? enc_json(data) : data
38
- rec.search_text = enc(searchable_text(data))
38
+ text = searchable_text(data)
39
+ rec.search_text = enc(text)
40
+ rec.search_tokens = search_tokens_for(text) if LlmemoryEpisode.column_names.include?("search_tokens")
39
41
  rec.created_at ||= Time.current
40
42
  rec.save!
41
43
  id
@@ -56,8 +58,10 @@ module Llmemory
56
58
  end
57
59
 
58
60
  def search_episodes(user_id, query)
59
- token_scope(LlmemoryEpisode.where(user_id: user_id, archived_at: nil), "search_text", query)
60
- .order(created_at: :desc).map { |r| decode_data(r.data) }
61
+ scope = LlmemoryEpisode.where(user_id: user_id, archived_at: nil)
62
+ token_scope(scope, "search_text", query, model: LlmemoryEpisode)
63
+ .sort_by { |r| -r.created_at.to_i }
64
+ .map { |r| decode_data(r.data) }
61
65
  end
62
66
 
63
67
  def count_episodes(user_id)
@@ -83,11 +87,25 @@ module Llmemory
83
87
 
84
88
  private
85
89
 
86
- def token_scope(scope, column, query)
90
+ def token_scope(scope, column, query, model:)
87
91
  tokens = Llmemory::Tokenizer.tokenize(query)
88
- return scope if tokens.empty?
89
- clause = tokens.map { "LOWER(#{column}) LIKE LOWER(?)" }.join(" OR ")
90
- scope.where(clause, *tokens.map { |t| "%#{t}%" })
92
+ return scope.to_a if tokens.empty?
93
+
94
+ if cipher.enabled? && model.column_names.include?("search_tokens")
95
+ digests = tokens.map { |t| cipher.blind_index(t) }
96
+ clause = digests.map { "search_tokens LIKE ?" }.join(" OR ")
97
+ indexed = scope.where(clause, *digests.map { |d| "% #{d} %" })
98
+ legacy_scope = scope.where(search_tokens: nil)
99
+ indexed_records = indexed.to_a
100
+ legacy_records = legacy_scope.to_a.select do |record|
101
+ plaintext = dec(record.public_send(column))
102
+ Llmemory::Tokenizer.matches?(plaintext, query)
103
+ end
104
+ (indexed_records + legacy_records).uniq { |r| r.id }
105
+ else
106
+ clause = tokens.map { "LOWER(#{column}) LIKE LOWER(?)" }.join(" OR ")
107
+ scope.where(clause, *tokens.map { |t| "%#{t}%" }).to_a
108
+ end
91
109
  end
92
110
 
93
111
  def stringify(hash)
@@ -29,10 +29,10 @@ module Llmemory
29
29
  data[:created_at] ||= Time.now.utc.iso8601
30
30
  search = searchable_text(data)
31
31
  conn.exec_params(
32
- "INSERT INTO llmemory_episodes (id, user_id, data, search_text, created_at) " \
33
- "VALUES ($1, $2, $3::jsonb, $4, $5) " \
34
- "ON CONFLICT (id) DO UPDATE SET data = $3::jsonb, search_text = $4",
35
- [id, user_id, store_data(data), enc(search), created_at_value(data)]
32
+ "INSERT INTO llmemory_episodes (id, user_id, data, search_text, search_tokens, created_at) " \
33
+ "VALUES ($1, $2, $3::jsonb, $4, $5, $6) " \
34
+ "ON CONFLICT (id) DO UPDATE SET data = $3::jsonb, search_text = $4, search_tokens = $5",
35
+ [id, user_id, store_data(data), enc(search), search_tokens_for(search), created_at_value(data)]
36
36
  )
37
37
  id
38
38
  end
@@ -53,11 +53,30 @@ module Llmemory
53
53
 
54
54
  def search_episodes(user_id, query)
55
55
  ensure_tables!
56
- suffix, params = token_filter("search_text", query, 2)
57
- conn.exec_params(
56
+ tokens = Llmemory::Tokenizer.tokenize(query)
57
+ return conn.exec_params(
58
+ "SELECT data FROM llmemory_episodes WHERE user_id = $1 AND archived_at IS NULL ORDER BY created_at DESC",
59
+ [user_id]
60
+ ).map { |r| parse_data(r["data"]) } if tokens.empty?
61
+
62
+ suffix, params = blind_token_filter("search_text", query, 2, search_tokens_column: cipher.enabled? ? "search_tokens" : nil)
63
+ rows = conn.exec_params(
58
64
  "SELECT data FROM llmemory_episodes WHERE user_id = $1 AND archived_at IS NULL#{suffix} ORDER BY created_at DESC",
59
65
  [user_id, *params]
60
- ).map { |r| parse_data(r["data"]) }
66
+ )
67
+ episodes = rows.map { |r| parse_data(r["data"]) }
68
+ return episodes unless cipher.enabled?
69
+
70
+ legacy_rows = conn.exec_params(
71
+ "SELECT data FROM llmemory_episodes WHERE user_id = $1 AND archived_at IS NULL AND search_tokens IS NULL",
72
+ [user_id]
73
+ )
74
+ legacy = legacy_rows.map { |r| parse_data(r["data"]) }.select do |ep|
75
+ Llmemory::Tokenizer.matches?(searchable_text(ep), query)
76
+ end
77
+ by_id = {}
78
+ (episodes + legacy).each { |ep| by_id[ep[:id] || ep["id"]] = ep }
79
+ by_id.values
61
80
  end
62
81
 
63
82
  def count_episodes(user_id)
@@ -116,16 +135,14 @@ module Llmemory
116
135
  );
117
136
  CREATE INDEX IF NOT EXISTS idx_llmemory_episodes_user_id ON llmemory_episodes(user_id);
118
137
  ALTER TABLE llmemory_episodes ADD COLUMN IF NOT EXISTS archived_at TIMESTAMPTZ;
138
+ ALTER TABLE llmemory_episodes ADD COLUMN IF NOT EXISTS search_tokens TEXT;
119
139
  SQL
120
140
  end
121
141
 
122
142
  # OR-of-token LIKE filter (see file-based DatabaseStorage). [""] for an
123
143
  # empty query => match all.
124
144
  def token_filter(column, query, start_index)
125
- tokens = Llmemory::Tokenizer.tokenize(query)
126
- return ["", []] if tokens.empty?
127
- likes = tokens.each_index.map { |i| "LOWER(#{column}) LIKE $#{start_index + i}" }
128
- [" AND (#{likes.join(' OR ')})", tokens.map { |t| "%#{t}%" }]
145
+ blind_token_filter(column, query, start_index, search_tokens_column: cipher.enabled? ? "search_tokens" : nil)
129
146
  end
130
147
 
131
148
  def parse_data(value)
@@ -25,12 +25,14 @@ module Llmemory
25
25
 
26
26
  def save_resource(user_id, text)
27
27
  id = "res_#{SecureRandom.hex(8)}"
28
- LlmemoryResource.create!(
28
+ attrs = {
29
29
  id: id,
30
30
  user_id: user_id,
31
31
  text: enc(text),
32
32
  created_at: Time.current
33
- )
33
+ }
34
+ attrs[:search_tokens] = search_tokens_for(text) if LlmemoryResource.column_names.include?("search_tokens")
35
+ LlmemoryResource.create!(attrs)
34
36
  id
35
37
  end
36
38
 
@@ -48,6 +50,7 @@ module Llmemory
48
50
  if provenance && LlmemoryItem.column_names.include?("provenance")
49
51
  attrs[:provenance] = cipher.enabled? ? enc_json(provenance) : provenance
50
52
  end
53
+ attrs[:search_tokens] = search_tokens_for(content) if LlmemoryItem.column_names.include?("search_tokens")
51
54
  LlmemoryItem.create!(attrs)
52
55
  id
53
56
  end
@@ -70,11 +73,13 @@ module Llmemory
70
73
  end
71
74
 
72
75
  def search_items(user_id, query)
73
- token_scope(LlmemoryItem.where(user_id: user_id), "content", query).map { |r| row_to_item(r) }
76
+ scope = LlmemoryItem.where(user_id: user_id)
77
+ token_scope(scope, "content", query, model: LlmemoryItem).map { |r| row_to_item(r) }
74
78
  end
75
79
 
76
80
  def search_resources(user_id, query)
77
- token_scope(LlmemoryResource.where(user_id: user_id), "text", query).map { |r| row_to_resource(r) }
81
+ scope = LlmemoryResource.where(user_id: user_id)
82
+ token_scope(scope, "text", query, model: LlmemoryResource).map { |r| row_to_resource(r) }
78
83
  end
79
84
 
80
85
  def get_resources_since(user_id, hours:)
@@ -112,6 +117,7 @@ module Llmemory
112
117
  created_at: created_at
113
118
  }
114
119
  attrs[:importance] = merged_item[:importance] if LlmemoryItem.column_names.include?("importance") && merged_item[:importance]
120
+ attrs[:search_tokens] = search_tokens_for(merged_item[:content]) if LlmemoryItem.column_names.include?("search_tokens")
115
121
  LlmemoryItem.create!(attrs)
116
122
  end
117
123
 
@@ -150,52 +156,40 @@ module Llmemory
150
156
  end
151
157
 
152
158
  def get_items_around(user_id, reference, before: 5, after: 5)
153
- items = get_all_items(user_id)
154
- find_around(items, reference, before, after)
159
+ find_around(get_all_items(user_id), reference, before, after)
155
160
  end
156
161
 
157
162
  def get_resources_around(user_id, reference, before: 5, after: 5)
158
- resources = get_all_resources(user_id)
159
- find_around(resources, reference, before, after)
163
+ find_around(get_all_resources(user_id), reference, before, after)
160
164
  end
161
165
 
162
166
  private
163
167
 
164
- def find_around(items, reference, before, after)
165
- return { before: [], target: nil, after: [] } if items.empty?
168
+ def token_scope(scope, column, query, model:)
169
+ tokens = Llmemory::Tokenizer.tokenize(query)
170
+ return scope if tokens.empty?
166
171
 
167
- idx = if reference.is_a?(String) && reference.match?(/^\d{4}-/)
168
- target_time = Time.parse(reference)
169
- items.index { |i| i[:created_at] >= target_time } || items.size
172
+ if cipher.enabled? && model.column_names.include?("search_tokens")
173
+ digests = tokens.map { |t| cipher.blind_index(t) }
174
+ clause = digests.map { "search_tokens LIKE ?" }.join(" OR ")
175
+ indexed = scope.where(clause, *digests.map { |d| "% #{d} %" })
176
+ legacy_scope = scope.where(search_tokens: nil)
177
+ indexed_records = indexed.to_a
178
+ legacy_records = legacy_scope.to_a.select do |record|
179
+ plaintext = dec(record.public_send(column))
180
+ Llmemory::Tokenizer.matches?(plaintext, query)
181
+ end
182
+ (indexed_records + legacy_records).uniq { |r| r.id }
170
183
  else
171
- items.index { |i| i[:id] == reference }
184
+ clause = tokens.map { "LOWER(#{column}) LIKE LOWER(?)" }.join(" OR ")
185
+ scope.where(clause, *tokens.map { |t| "%#{sanitize_like(t)}%" }).to_a
172
186
  end
173
-
174
- return { before: [], target: nil, after: [] } unless idx
175
-
176
- start_idx = [idx - before, 0].max
177
- end_idx = [idx + after, items.size - 1].min
178
-
179
- {
180
- before: items[start_idx...idx] || [],
181
- target: items[idx],
182
- after: items[(idx + 1)..end_idx] || []
183
- }
184
187
  end
185
188
 
186
189
  def sanitize_like(str)
187
190
  (str || "").to_s.gsub(/[%_\\]/) { |c| "\\#{c}" }
188
191
  end
189
192
 
190
- # OR-of-token LIKE scope for keyword search; unchanged scope (match all)
191
- # when the query has no tokens.
192
- def token_scope(scope, column, query)
193
- tokens = Llmemory::Tokenizer.tokenize(query)
194
- return scope if tokens.empty?
195
- clause = tokens.map { "LOWER(#{column}) LIKE LOWER(?)" }.join(" OR ")
196
- scope.where(clause, *tokens.map { |t| "%#{sanitize_like(t)}%" })
197
- end
198
-
199
193
  def row_to_item(r)
200
194
  h = {
201
195
  id: r.id,
@@ -88,6 +88,30 @@ module Llmemory
88
88
  def get_resources_around(user_id, reference, before: 5, after: 5)
89
89
  raise NotImplementedError, "#{self.class}#get_resources_around must be implemented"
90
90
  end
91
+
92
+ protected
93
+
94
+ def find_around(items, reference, before, after, id_key: :id)
95
+ return { before: [], target: nil, after: [] } if items.empty?
96
+
97
+ idx = if reference.is_a?(String) && reference.match?(/^\d{4}-/)
98
+ target_time = Time.parse(reference)
99
+ items.index { |i| (i[:created_at] || i["created_at"]) >= target_time } || items.size
100
+ else
101
+ items.index { |i| (i[id_key] || i[id_key.to_s]) == reference }
102
+ end
103
+
104
+ return { before: [], target: nil, after: [] } unless idx
105
+
106
+ start_idx = [idx - before, 0].max
107
+ end_idx = [idx + after, items.size - 1].min
108
+
109
+ {
110
+ before: items[start_idx...idx] || [],
111
+ target: items[idx],
112
+ after: items[(idx + 1)..end_idx] || []
113
+ }
114
+ end
91
115
  end
92
116
  end
93
117
  end
@@ -22,8 +22,8 @@ module Llmemory
22
22
  ensure_tables!
23
23
  id = "res_#{SecureRandom.hex(8)}"
24
24
  conn.exec_params(
25
- "INSERT INTO llmemory_resources (id, user_id, text, created_at) VALUES ($1, $2, $3, $4)",
26
- [id, user_id, enc(text), Time.now.utc.iso8601]
25
+ "INSERT INTO llmemory_resources (id, user_id, text, search_tokens, created_at) VALUES ($1, $2, $3, $4, $5)",
26
+ [id, user_id, enc(text), search_tokens_for(text), Time.now.utc.iso8601]
27
27
  )
28
28
  id
29
29
  end
@@ -32,8 +32,8 @@ module Llmemory
32
32
  ensure_tables!
33
33
  id = "item_#{SecureRandom.hex(8)}"
34
34
  conn.exec_params(
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)",
36
- [id, user_id, category, enc(content), source_resource_id, importance.to_f, provenance_json(provenance), Time.now.utc.iso8601]
35
+ "INSERT INTO llmemory_items (id, user_id, category, content, source_resource_id, importance, provenance, search_tokens, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9)",
36
+ [id, user_id, category, enc(content), source_resource_id, importance.to_f, provenance_json(provenance), search_tokens_for(content), Time.now.utc.iso8601]
37
37
  )
38
38
  id
39
39
  end
@@ -69,22 +69,34 @@ module Llmemory
69
69
 
70
70
  def search_items(user_id, query)
71
71
  ensure_tables!
72
- suffix, params = token_filter("content", query, 2)
72
+ tokens = Llmemory::Tokenizer.tokenize(query)
73
+ return get_all_items(user_id) if tokens.empty?
74
+
75
+ suffix, params = blind_token_filter("content", query, 2, search_tokens_column: cipher.enabled? ? "search_tokens" : nil)
73
76
  rows = conn.exec_params(
74
77
  "SELECT id, category, content, source_resource_id, importance, provenance, created_at FROM llmemory_items WHERE user_id = $1#{suffix}",
75
78
  [user_id, *params]
76
79
  )
77
- rows_to_items(rows)
80
+ items = rows_to_items(rows)
81
+ return items unless cipher.enabled?
82
+
83
+ merge_legacy_search(items, legacy_item_rows(user_id), query, text_key: :content)
78
84
  end
79
85
 
80
86
  def search_resources(user_id, query)
81
87
  ensure_tables!
82
- suffix, params = token_filter("text", query, 2)
88
+ tokens = Llmemory::Tokenizer.tokenize(query)
89
+ return get_all_resources(user_id) if tokens.empty?
90
+
91
+ suffix, params = blind_token_filter("text", query, 2, search_tokens_column: cipher.enabled? ? "search_tokens" : nil)
83
92
  rows = conn.exec_params(
84
93
  "SELECT id, text, created_at FROM llmemory_resources WHERE user_id = $1#{suffix}",
85
94
  [user_id, *params]
86
95
  )
87
- rows_to_resources(rows)
96
+ resources = rows_to_resources(rows)
97
+ return resources unless cipher.enabled?
98
+
99
+ merge_legacy_search(resources, legacy_resource_rows(user_id), query, text_key: :text)
88
100
  end
89
101
 
90
102
  def get_resources_since(user_id, hours:)
@@ -144,13 +156,14 @@ module Llmemory
144
156
  created_at = created_at.utc.iso8601 if created_at.respond_to?(:utc)
145
157
  id = "item_#{SecureRandom.hex(8)}"
146
158
  conn.exec_params(
147
- "INSERT INTO llmemory_items (id, user_id, category, content, source_resource_id, created_at) VALUES ($1, $2, $3, $4, $5, $6)",
159
+ "INSERT INTO llmemory_items (id, user_id, category, content, source_resource_id, search_tokens, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)",
148
160
  [
149
161
  id,
150
162
  user_id,
151
163
  merged_item[:category],
152
164
  enc(merged_item[:content]),
153
165
  merged_item[:source_resource_id],
166
+ search_tokens_for(merged_item[:content]),
154
167
  created_at
155
168
  ]
156
169
  )
@@ -205,38 +218,42 @@ module Llmemory
205
218
 
206
219
  def get_items_around(user_id, reference, before: 5, after: 5)
207
220
  ensure_tables!
208
- items = get_all_items(user_id)
209
- find_around(items, reference, before, after)
221
+ find_around(get_all_items(user_id), reference, before, after)
210
222
  end
211
223
 
212
224
  def get_resources_around(user_id, reference, before: 5, after: 5)
213
225
  ensure_tables!
214
- resources = get_all_resources(user_id)
215
- find_around(resources, reference, before, after)
226
+ find_around(get_all_resources(user_id), reference, before, after)
216
227
  end
217
228
 
218
229
  private
219
230
 
220
- def find_around(items, reference, before, after)
221
- return { before: [], target: nil, after: [] } if items.empty?
222
-
223
- idx = if reference.is_a?(String) && reference.match?(/^\d{4}-/)
224
- target_time = Time.parse(reference)
225
- items.index { |i| i[:created_at] >= target_time } || items.size
226
- else
227
- items.index { |i| i[:id] == reference }
228
- end
231
+ def legacy_item_rows(user_id)
232
+ conn.exec_params(
233
+ "SELECT id, category, content, source_resource_id, importance, provenance, created_at FROM llmemory_items WHERE user_id = $1 AND search_tokens IS NULL",
234
+ [user_id]
235
+ )
236
+ end
229
237
 
230
- return { before: [], target: nil, after: [] } unless idx
238
+ def legacy_resource_rows(user_id)
239
+ conn.exec_params(
240
+ "SELECT id, text, created_at FROM llmemory_resources WHERE user_id = $1 AND search_tokens IS NULL",
241
+ [user_id]
242
+ )
243
+ end
231
244
 
232
- start_idx = [idx - before, 0].max
233
- end_idx = [idx + after, items.size - 1].min
245
+ def merge_legacy_search(indexed, legacy_rows, query, text_key:)
246
+ return indexed if legacy_rows.none?
234
247
 
235
- {
236
- before: items[start_idx...idx] || [],
237
- target: items[idx],
238
- after: items[(idx + 1)..end_idx] || []
239
- }
248
+ legacy = if text_key == :content
249
+ rows_to_items(legacy_rows)
250
+ else
251
+ rows_to_resources(legacy_rows)
252
+ end
253
+ legacy_matches = legacy.select { |row| Llmemory::Tokenizer.matches?(row[text_key], query) }
254
+ by_id = {}
255
+ (indexed + legacy_matches).each { |row| by_id[row[:id]] = row }
256
+ by_id.values
240
257
  end
241
258
 
242
259
  def conn
@@ -271,6 +288,8 @@ module Llmemory
271
288
  SQL
272
289
  conn.exec("ALTER TABLE llmemory_items ADD COLUMN IF NOT EXISTS importance REAL DEFAULT 0.7") rescue nil
273
290
  conn.exec("ALTER TABLE llmemory_items ADD COLUMN IF NOT EXISTS provenance JSONB") rescue nil
291
+ conn.exec("ALTER TABLE llmemory_items ADD COLUMN IF NOT EXISTS search_tokens TEXT") rescue nil
292
+ conn.exec("ALTER TABLE llmemory_resources ADD COLUMN IF NOT EXISTS search_tokens TEXT") rescue nil
274
293
  conn.exec(<<~SQL)
275
294
  CREATE TABLE IF NOT EXISTS llmemory_categories (
276
295
  user_id TEXT NOT NULL,
@@ -300,10 +319,7 @@ module Llmemory
300
319
  # ["" , []] for an empty query (match all). Tokens are [a-z0-9]{2,} so
301
320
  # they carry no LIKE wildcards.
302
321
  def token_filter(column, query, start_index)
303
- tokens = Llmemory::Tokenizer.tokenize(query)
304
- return ["", []] if tokens.empty?
305
- likes = tokens.each_index.map { |i| "LOWER(#{column}) LIKE $#{start_index + i}" }
306
- [" AND (#{likes.join(' OR ')})", tokens.map { |t| "%#{t}%" }]
322
+ blind_token_filter(column, query, start_index)
307
323
  end
308
324
 
309
325
  def parse_provenance(value)
@@ -91,7 +91,7 @@ module Llmemory
91
91
  data = decrypt_item(data)
92
92
  data[:created_at] = parse_time(data[:created_at])
93
93
  data
94
- end.sort_by { |i| i[:created_at] }
94
+ end.sort_by { |i| [i[:created_at], i[:id].to_s] }
95
95
  end
96
96
 
97
97
  def get_all_resources(user_id)
@@ -104,7 +104,7 @@ module Llmemory
104
104
  data[:text] = dec(data[:text] || data["text"])
105
105
  data[:created_at] = parse_time(data[:created_at])
106
106
  data
107
- end.sort_by { |r| r[:created_at] }
107
+ end.sort_by { |r| [r[:created_at], r[:id].to_s] }
108
108
  end
109
109
 
110
110
  def get_items_since(user_id, hours:)
@@ -177,6 +177,14 @@ module Llmemory
177
177
  get_all_items(user_id).size
178
178
  end
179
179
 
180
+ def get_items_around(user_id, reference, before: 5, after: 5)
181
+ find_around(get_all_items(user_id), reference, before, after)
182
+ end
183
+
184
+ def get_resources_around(user_id, reference, before: 5, after: 5)
185
+ find_around(get_all_resources(user_id), reference, before, after)
186
+ end
187
+
180
188
  private
181
189
 
182
190
  def user_path(user_id, *parts)
@@ -121,38 +121,12 @@ module Llmemory
121
121
 
122
122
  def get_items_around(user_id, reference, before: 5, after: 5)
123
123
  items = @items[user_id].sort_by { |i| i[:created_at] }
124
- find_around(items, reference, :id, before, after)
124
+ find_around(items, reference, before, after)
125
125
  end
126
126
 
127
127
  def get_resources_around(user_id, reference, before: 5, after: 5)
128
128
  resources = @resources[user_id].sort_by { |r| r[:created_at] }
129
- find_around(resources, reference, :id, before, after)
130
- end
131
-
132
- private
133
-
134
- def find_around(items, reference, id_key, before, after)
135
- return { before: [], target: nil, after: [] } if items.empty?
136
-
137
- idx = if reference.is_a?(String) && reference.match?(/^\d{4}-/)
138
- # ISO timestamp - find closest item at or after this time
139
- target_time = Time.parse(reference)
140
- items.index { |i| i[:created_at] >= target_time } || items.size
141
- else
142
- # Item ID
143
- items.index { |i| i[id_key] == reference }
144
- end
145
-
146
- return { before: [], target: nil, after: [] } unless idx
147
-
148
- start_idx = [idx - before, 0].max
149
- end_idx = [idx + after, items.size - 1].min
150
-
151
- {
152
- before: items[start_idx...idx] || [],
153
- target: items[idx],
154
- after: items[(idx + 1)..end_idx] || []
155
- }
129
+ find_around(resources, reference, before, after)
156
130
  end
157
131
  end
158
132
  end
@@ -162,6 +162,7 @@ module Llmemory
162
162
 
163
163
  edge_text = "#{subject} #{predicate} #{object}"
164
164
  embedding = @vector_store.respond_to?(:embed) ? @vector_store.embed(edge_text) : nil
165
+ record_embed_usage(@vector_store) if embedding
165
166
  if embedding && @vector_store.respond_to?(:store)
166
167
  @vector_store.store(id: edge_id, embedding: embedding, metadata: { text: edge_text, created_at: Time.now }, user_id: @user_id)
167
168
  end
@@ -172,8 +173,10 @@ module Llmemory
172
173
  vector_results = []
173
174
  if @vector_store.respond_to?(:search_by_text)
174
175
  vector_results = @vector_store.search_by_text(query.to_s, top_k: top_k, user_id: @user_id)
176
+ record_embed_usage(@vector_store)
175
177
  elsif @vector_store.respond_to?(:embed) && @vector_store.respond_to?(:search)
176
178
  emb = @vector_store.embed(query.to_s)
179
+ record_embed_usage(@vector_store)
177
180
  vector_results = @vector_store.search(emb, top_k: top_k, user_id: @user_id)
178
181
  end
179
182
 
@@ -231,6 +234,16 @@ module Llmemory
231
234
  lines << "=== END MEMORIES ==="
232
235
  lines.join("\n")
233
236
  end
237
+
238
+ def record_embed_usage(vector_store)
239
+ return unless vector_store
240
+
241
+ Llmemory::LLM::UsageRecorder.record_embed_from_store(
242
+ user_id: @user_id,
243
+ vector_store: vector_store,
244
+ store: Llmemory::ShortTerm::Stores.build(cipher: @cipher)
245
+ )
246
+ end
234
247
  end
235
248
  end
236
249
  end
@@ -16,7 +16,9 @@ module Llmemory
16
16
  require_relative "storages/active_record_storage"
17
17
  ActiveRecordStorage.new(cipher: resolved_cipher)
18
18
  else
19
- MemoryStorage.new
19
+ store_name = (store || Llmemory.configuration.long_term_store).to_s
20
+ raise Llmemory::ConfigurationError,
21
+ "graph_based long-term memory supports long_term_store :memory or :active_record; got #{store_name.inspect}"
20
22
  end
21
23
  end
22
24
  end
@@ -141,6 +141,7 @@ module Llmemory
141
141
  vs = vector_store
142
142
  return if vs.nil? || text.to_s.strip.empty?
143
143
  embedding = vs.embed(text)
144
+ record_embed_usage(vs)
144
145
  return unless embedding
145
146
  vs.store(id: id, embedding: embedding, metadata: { text: text, created_at: Time.now }, user_id: @user_id)
146
147
  rescue StandardError
@@ -148,7 +149,9 @@ module Llmemory
148
149
  end
149
150
 
150
151
  def vector_candidates(query, top_k, vs)
151
- vs.search_by_text(query.to_s, top_k: top_k, user_id: @user_id).filter_map do |r|
152
+ results = vs.search_by_text(query.to_s, top_k: top_k, user_id: @user_id)
153
+ record_embed_usage(vs)
154
+ results.filter_map do |r|
152
155
  raw = @storage.get_skill(@user_id, r[:id] || r["id"])
153
156
  raw && candidate_for(raw, (r[:score] || r["score"] || 1.0).to_f)
154
157
  end
@@ -178,6 +181,14 @@ module Llmemory
178
181
  end
179
182
  by_id.values.sort_by { |c| -c[:score].to_f }.first(top_k)
180
183
  end
184
+
185
+ def record_embed_usage(vector_store)
186
+ Llmemory::LLM::UsageRecorder.record_embed_from_store(
187
+ user_id: @user_id,
188
+ vector_store: vector_store,
189
+ store: Llmemory::ShortTerm::Stores.build(cipher: @cipher)
190
+ )
191
+ end
181
192
  end
182
193
  end
183
194
  end