llmemory 0.2.4 → 0.2.6

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 296b9d61d6c474145ecaa607653b37438b2491c846aac602f65d5fd850dae9ef
4
- data.tar.gz: 521fd05b577c6c17a7dbc5d3771ff9fb3f7cddeaeef31938efabdcfd40db74a5
3
+ metadata.gz: bc40425c3adccdf3b900f4421cb642feb9e816df10934fe5fe360f2bb2a16706
4
+ data.tar.gz: b1267ceae049b4c69655987b15fda8517616bde9f2048301bdc030f4d92b1259
5
5
  SHA512:
6
- metadata.gz: edf6ee6b41cb366f2ddef5ac2deec9c3c03090d4920d2386f9578e31999d135b34ad7f995ee9a9e803cf43627f969a3fab9ee191c68e2362a4d62fa8bade0729
7
- data.tar.gz: 83266918faeb2bb4f7b57e89007b642283d7d64600bf83513948d5378a625fa911aefa6823c54115961bafe34dfdd147f4fb297a8226caad49aaceb6a01372e0
6
+ metadata.gz: f81850bc29cdeeec37d1d590442376bba2e473e895d0dda7f5be2c0c41d86b7e9bac0b0e2e1e731895a6ae978da78ca68be038ae5d9976bfcd4a1ec5702adec6
7
+ data.tar.gz: 12d5f1736a795336d3ef951b5fde2ef1e89b1d0a193e00d00fc2de88e5337beee309198dd016ed7e4980598a85c342387713652b66e19fadb2455c853a7a5d94
data/README.md CHANGED
@@ -14,6 +14,8 @@ gem "llmemory"
14
14
 
15
15
  Then run `bundle install`.
16
16
 
17
+ **Upgrading?** See the [Migration guide](migration/MIGRATION.md) for database columns, encryption backfill, and graph store configuration changes.
18
+
17
19
  ## Quick Start (Unified API)
18
20
 
19
21
  The recommended way to use llmemory in a chat is the unified `Llmemory::Memory` API. It abstracts short-term (conversation history) and long-term (extracted facts) and combines retrieval from both:
@@ -171,7 +173,7 @@ memory = Llmemory::Memory.new(user_id: "agent-1", encryption_key: "tenant-specif
171
173
  **What is encrypted:** conversation checkpoints (redis/postgres/active_record), file-based facts/resources/categories, episodic/procedural documents, graph node names/types/predicates (deterministic) and properties (random IV). **Vector embeddings are not encrypted** (required for pgvector search); associated `text_content` metadata is encrypted.
172
174
 
173
175
  **Trade-offs:**
174
- - Database keyword search (`LIKE`, BM25 on encrypted columns) no longer works on ciphertext; file backends still search in memory after decrypt.
176
+ - Database keyword search on encrypted columns requires the `search_tokens` blind-index column (see [Migration guide](migration/MIGRATION.md)); file backends search in memory after decrypt without schema changes.
175
177
  - `:memory` backends are in-process only and are **not** encrypted at rest.
176
178
  - Existing plaintext data remains readable (markers `enc:v1:` / `encd:v1:`); new writes are encrypted when enabled.
177
179
  - Deterministic encryption on graph identifiers leaks equality (same name ⇒ same ciphertext) but keeps graph traversal working.
@@ -6,6 +6,7 @@ class CreateLlmemoryTables < ActiveRecord::Migration[7.0]
6
6
  t.string :id, null: false, primary_key: true
7
7
  t.string :user_id, null: false
8
8
  t.text :text, null: false
9
+ t.text :search_tokens
9
10
  t.timestamps
10
11
  end
11
12
  add_index :llmemory_resources, :user_id
@@ -18,6 +19,7 @@ class CreateLlmemoryTables < ActiveRecord::Migration[7.0]
18
19
  t.string :source_resource_id
19
20
  t.float :importance, default: 0.7
20
21
  t.jsonb :provenance
22
+ t.text :search_tokens
21
23
  t.timestamps
22
24
  end
23
25
  add_index :llmemory_items, :user_id
@@ -36,6 +38,7 @@ class CreateLlmemoryTables < ActiveRecord::Migration[7.0]
36
38
  t.string :user_id, null: false
37
39
  t.jsonb :data, null: false, default: {}
38
40
  t.text :search_text
41
+ t.text :search_tokens
39
42
  t.datetime :archived_at
40
43
  t.timestamps
41
44
  end
@@ -47,6 +50,8 @@ class CreateLlmemoryTables < ActiveRecord::Migration[7.0]
47
50
  t.string :user_id, null: false
48
51
  t.jsonb :data, null: false, default: {}
49
52
  t.text :search_text
53
+ t.text :search_tokens
54
+ t.text :name_det
50
55
  t.datetime :archived_at
51
56
  t.timestamps
52
57
  end
@@ -36,6 +36,10 @@ module Llmemory
36
36
  def encrypted?(str)
37
37
  false
38
38
  end
39
+
40
+ def blind_index(token)
41
+ token.to_s.downcase
42
+ end
39
43
  end
40
44
 
41
45
  # AES-256-GCM encryption with separate content (random IV) and index
@@ -111,6 +115,14 @@ module Llmemory
111
115
  s.start_with?(MARKER) || s.start_with?(DETERMINISTIC_MARKER)
112
116
  end
113
117
 
118
+ # Deterministic HMAC digest for blind-index keyword search over encrypted fields.
119
+ def blind_index(token)
120
+ str = token.to_s.downcase
121
+ return str if str.empty?
122
+
123
+ OpenSSL::HMAC.hexdigest("SHA256", @index_key, str)[0, 32]
124
+ end
125
+
114
126
  private
115
127
 
116
128
  def encrypt_with_key(plaintext, key, iv:, marker:)
@@ -105,6 +105,31 @@ module Llmemory
105
105
  rescue JSON::ParserError
106
106
  nil
107
107
  end
108
+
109
+ # Space-delimited blind-index digests for encrypted keyword search.
110
+ def search_tokens_for(text)
111
+ tokens = Llmemory::Tokenizer.tokenize(text).uniq
112
+ return nil if tokens.empty?
113
+
114
+ digests = tokens.map { |t| cipher.blind_index(t) }
115
+ " #{digests.join(' ')} "
116
+ end
117
+
118
+ # OR-of-token SQL filter. Uses blind index on `search_tokens_column` when
119
+ # encryption is enabled; otherwise LIKE on the plaintext `column`.
120
+ def blind_token_filter(column, query, start_index, search_tokens_column: nil)
121
+ tokens = Llmemory::Tokenizer.tokenize(query)
122
+ return ["", []] if tokens.empty?
123
+
124
+ if cipher.enabled? && search_tokens_column
125
+ digests = tokens.map { |t| cipher.blind_index(t) }
126
+ likes = digests.each_index.map { |i| "#{search_tokens_column} LIKE $#{start_index + i}" }
127
+ [" AND (#{likes.join(' OR ')})", digests.map { |d| "% #{d} %" }]
128
+ else
129
+ likes = tokens.each_index.map { |i| "LOWER(#{column}) LIKE $#{start_index + i}" }
130
+ [" AND (#{likes.join(' OR ')})", tokens.map { |t| "%#{t}%" }]
131
+ end
132
+ end
108
133
  end
109
134
  end
110
135
  end
@@ -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
@@ -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