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.
- checksums.yaml +4 -4
- data/README.md +37 -2
- data/lib/generators/llmemory/install/templates/create_llmemory_tables.rb +5 -0
- data/lib/llmemory/cli/commands/stats.rb +5 -0
- data/lib/llmemory/crypto/cipher.rb +12 -0
- data/lib/llmemory/crypto/field_helpers.rb +25 -0
- data/lib/llmemory/instrumentation.rb +4 -2
- data/lib/llmemory/llm/anthropic.rb +8 -3
- data/lib/llmemory/llm/base.rb +42 -0
- data/lib/llmemory/llm/openai.rb +27 -12
- data/lib/llmemory/llm/response.rb +18 -0
- data/lib/llmemory/llm/tracking_client.rb +61 -0
- data/lib/llmemory/llm/usage.rb +31 -0
- data/lib/llmemory/llm/usage_ledger.rb +118 -0
- data/lib/llmemory/llm/usage_recorder.rb +37 -0
- data/lib/llmemory/llm.rb +5 -0
- data/lib/llmemory/long_term/episodic/memory.rb +12 -1
- 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/memory.rb +13 -0
- data/lib/llmemory/long_term/graph_based/storage.rb +3 -1
- data/lib/llmemory/long_term/procedural/memory.rb +12 -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_stats.rb +13 -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/memory.rb +34 -15
- data/lib/llmemory/short_term/checkpoint.rb +2 -0
- data/lib/llmemory/vector_store/active_record_store.rb +6 -0
- data/lib/llmemory/vector_store/memory_store.rb +13 -3
- data/lib/llmemory/vector_store/openai_embeddings.rb +23 -2
- data/lib/llmemory/version.rb +1 -1
- data/lib/tasks/llmemory.rake +37 -0
- 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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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)
|
|
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
|