llmemory 0.2.4 → 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.
- checksums.yaml +4 -4
- data/README.md +3 -1
- data/lib/generators/llmemory/install/templates/create_llmemory_tables.rb +5 -0
- data/lib/llmemory/crypto/cipher.rb +12 -0
- data/lib/llmemory/crypto/field_helpers.rb +25 -0
- data/lib/llmemory/long_term/episodic/storages/active_record_storage.rb +25 -7
- data/lib/llmemory/long_term/episodic/storages/database_storage.rb +28 -11
- data/lib/llmemory/long_term/file_based/storages/active_record_storage.rb +28 -34
- data/lib/llmemory/long_term/file_based/storages/base.rb +24 -0
- data/lib/llmemory/long_term/file_based/storages/database_storage.rb +50 -34
- data/lib/llmemory/long_term/file_based/storages/file_storage.rb +10 -2
- data/lib/llmemory/long_term/file_based/storages/memory_storage.rb +2 -28
- data/lib/llmemory/long_term/graph_based/storage.rb +3 -1
- data/lib/llmemory/long_term/procedural/storages/active_record_storage.rb +40 -9
- data/lib/llmemory/long_term/procedural/storages/database_storage.rb +57 -17
- data/lib/llmemory/maintenance/search_tokens_backfill.rb +339 -0
- data/lib/llmemory/maintenance.rb +1 -0
- data/lib/llmemory/mcp/tools/memory_retrieve.rb +2 -0
- data/lib/llmemory/mcp/tools/memory_timeline.rb +48 -14
- data/lib/llmemory/mcp/tools/memory_timeline_context.rb +15 -5
- data/lib/llmemory/vector_store/memory_store.rb +7 -3
- data/lib/llmemory/version.rb +1 -1
- data/lib/tasks/llmemory.rake +37 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eb36583aecef9a4a0d4a89e703d1edf7326112df260ceccd1b181cf1411a45d1
|
|
4
|
+
data.tar.gz: b91c1b67b6c88c12d0515a03304aa04b0f61bc310acbb6e1b02391fbe9944d08
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 597d6cd707b07ebc3c754e2ac67269143c6bff221a22529e692b8a4cc4fc4415180b83f97d7336f95e6eef025c75a18f64819daa740588f032052fccca1a3632
|
|
7
|
+
data.tar.gz: 7b9ec61f4edb2cc053e86f1f478e7a23c82784732fbc918ca6bf35756583e568c92039c7a1cdf2939c6066df76e85ee99176f03b44c82ae708f2705093cb67e8
|
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](docs/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
|
|
176
|
+
- Database keyword search on encrypted columns requires the `search_tokens` blind-index column (see [Migration guide](docs/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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
165
|
-
|
|
168
|
+
def token_scope(scope, column, query, model:)
|
|
169
|
+
tokens = Llmemory::Tokenizer.tokenize(query)
|
|
170
|
+
return scope if tokens.empty?
|
|
166
171
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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
|
-
|
|
233
|
-
|
|
245
|
+
def merge_legacy_search(indexed, legacy_rows, query, text_key:)
|
|
246
|
+
return indexed if legacy_rows.none?
|
|
234
247
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|