llmemory 0.2.2 → 0.2.4
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 +65 -1
- data/lib/llmemory/cli/commands/stats.rb +5 -0
- data/lib/llmemory/configuration.rb +22 -2
- data/lib/llmemory/crypto/cipher.rb +147 -0
- data/lib/llmemory/crypto/field_helpers.rb +110 -0
- data/lib/llmemory/instrumentation.rb +4 -2
- data/lib/llmemory/llm/anthropic.rb +10 -4
- data/lib/llmemory/llm/base.rb +42 -0
- data/lib/llmemory/llm/openai.rb +29 -13
- 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 +16 -4
- data/lib/llmemory/long_term/episodic/storage.rb +11 -4
- data/lib/llmemory/long_term/episodic/storages/active_record_storage.rb +19 -6
- data/lib/llmemory/long_term/episodic/storages/database_storage.rb +25 -3
- data/lib/llmemory/long_term/episodic/storages/file_storage.rb +22 -5
- data/lib/llmemory/long_term/file_based/storage.rb +11 -4
- data/lib/llmemory/long_term/file_based/storages/active_record_storage.rb +16 -10
- data/lib/llmemory/long_term/file_based/storages/database_storage.rb +24 -8
- data/lib/llmemory/long_term/file_based/storages/file_storage.rb +28 -14
- data/lib/llmemory/long_term/graph_based/memory.rb +17 -3
- data/lib/llmemory/long_term/graph_based/storage.rb +3 -2
- data/lib/llmemory/long_term/graph_based/storages/active_record_storage.rb +47 -21
- data/lib/llmemory/long_term/procedural/memory.rb +16 -4
- data/lib/llmemory/long_term/procedural/storage.rb +11 -4
- data/lib/llmemory/long_term/procedural/storages/active_record_storage.rb +33 -13
- data/lib/llmemory/long_term/procedural/storages/database_storage.rb +25 -4
- data/lib/llmemory/long_term/procedural/storages/file_storage.rb +23 -6
- data/lib/llmemory/mcp/tools/memory_stats.rb +13 -0
- data/lib/llmemory/memory.rb +66 -15
- data/lib/llmemory/short_term/checkpoint.rb +5 -2
- data/lib/llmemory/short_term/stores/active_record_store.rb +12 -10
- data/lib/llmemory/short_term/stores/memory_store.rb +1 -1
- data/lib/llmemory/short_term/stores/postgres_store.rb +11 -5
- data/lib/llmemory/short_term/stores/redis_store.rb +7 -5
- data/lib/llmemory/short_term/stores.rb +7 -6
- data/lib/llmemory/vector_store/active_record_store.rb +30 -3
- data/lib/llmemory/vector_store/memory_store.rb +29 -3
- data/lib/llmemory/vector_store/openai_embeddings.rb +23 -2
- data/lib/llmemory/vector_store.rb +4 -3
- data/lib/llmemory/version.rb +1 -1
- data/lib/llmemory.rb +2 -0
- metadata +8 -1
|
@@ -4,6 +4,7 @@ require "json"
|
|
|
4
4
|
require "securerandom"
|
|
5
5
|
require "time"
|
|
6
6
|
require_relative "base"
|
|
7
|
+
require_relative "../../../crypto/field_helpers"
|
|
7
8
|
|
|
8
9
|
module Llmemory
|
|
9
10
|
module LongTerm
|
|
@@ -13,9 +14,12 @@ module Llmemory
|
|
|
13
14
|
# (plus id/user_id/created_at and a denormalized search_text), mirroring
|
|
14
15
|
# the file-based DatabaseStorage pattern.
|
|
15
16
|
class DatabaseStorage < Base
|
|
16
|
-
|
|
17
|
+
include Llmemory::Crypto::FieldHelpers
|
|
18
|
+
|
|
19
|
+
def initialize(database_url: nil, cipher: nil)
|
|
17
20
|
@database_url = database_url || Llmemory.configuration.database_url
|
|
18
21
|
@connection = nil
|
|
22
|
+
@cipher = cipher || Llmemory.build_cipher
|
|
19
23
|
end
|
|
20
24
|
|
|
21
25
|
def save_skill(user_id, skill)
|
|
@@ -27,7 +31,7 @@ module Llmemory
|
|
|
27
31
|
"INSERT INTO llmemory_skills (id, user_id, data, search_text, created_at) " \
|
|
28
32
|
"VALUES ($1, $2, $3::jsonb, $4, $5) " \
|
|
29
33
|
"ON CONFLICT (id) DO UPDATE SET data = $3::jsonb, search_text = $4",
|
|
30
|
-
[id, user_id,
|
|
34
|
+
[id, user_id, store_data(data), enc(searchable_text(data)), created_at_value(data)]
|
|
31
35
|
)
|
|
32
36
|
id
|
|
33
37
|
end
|
|
@@ -72,7 +76,7 @@ module Llmemory
|
|
|
72
76
|
data[:updated_at] = Time.now.utc.iso8601
|
|
73
77
|
conn.exec_params(
|
|
74
78
|
"UPDATE llmemory_skills SET data = $3::jsonb, search_text = $4 WHERE user_id = $1 AND id = $2",
|
|
75
|
-
[user_id, skill_id,
|
|
79
|
+
[user_id, skill_id, store_data(data), enc(searchable_text(data))]
|
|
76
80
|
)
|
|
77
81
|
data
|
|
78
82
|
end
|
|
@@ -144,11 +148,28 @@ module Llmemory
|
|
|
144
148
|
end
|
|
145
149
|
|
|
146
150
|
def parse_data(value)
|
|
147
|
-
|
|
151
|
+
if value.is_a?(Hash)
|
|
152
|
+
return value.transform_keys(&:to_sym)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
str = value.to_s
|
|
156
|
+
if cipher.encrypted?(str)
|
|
157
|
+
cipher.decrypt_json(str)
|
|
158
|
+
else
|
|
159
|
+
JSON.parse(str, symbolize_names: true)
|
|
160
|
+
end
|
|
148
161
|
rescue JSON::ParserError
|
|
149
162
|
{}
|
|
150
163
|
end
|
|
151
164
|
|
|
165
|
+
def store_data(data)
|
|
166
|
+
if cipher.enabled?
|
|
167
|
+
JSON.generate(enc_json(data))
|
|
168
|
+
else
|
|
169
|
+
JSON.generate(data)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
152
173
|
def symbolize(hash)
|
|
153
174
|
hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
154
175
|
end
|
|
@@ -4,22 +4,26 @@ require "fileutils"
|
|
|
4
4
|
require "json"
|
|
5
5
|
require "time"
|
|
6
6
|
require_relative "base"
|
|
7
|
+
require_relative "../../../crypto/field_helpers"
|
|
7
8
|
|
|
8
9
|
module Llmemory
|
|
9
10
|
module LongTerm
|
|
10
11
|
module Procedural
|
|
11
12
|
module Storages
|
|
12
13
|
class FileStorage < Base
|
|
13
|
-
|
|
14
|
+
include Llmemory::Crypto::FieldHelpers
|
|
15
|
+
|
|
16
|
+
def initialize(base_path: nil, cipher: nil)
|
|
14
17
|
@base_path = base_path || Llmemory.configuration.long_term_storage_path || "./llmemory_data"
|
|
15
18
|
@base_path = File.expand_path(@base_path)
|
|
19
|
+
@cipher = cipher || Llmemory.build_cipher
|
|
16
20
|
end
|
|
17
21
|
|
|
18
22
|
def save_skill(user_id, skill)
|
|
19
23
|
id = skill[:id] || skill["id"] || "skill_#{next_seq(user_id)}"
|
|
20
24
|
data = stringify_for_json(skill).merge("id" => id, "user_id" => user_id)
|
|
21
25
|
data["created_at"] ||= Time.now.iso8601(6)
|
|
22
|
-
|
|
26
|
+
write_skill_file(skill_path(user_id, id), data)
|
|
23
27
|
id
|
|
24
28
|
end
|
|
25
29
|
|
|
@@ -50,7 +54,7 @@ module Llmemory
|
|
|
50
54
|
key = success ? :success_count : :failure_count
|
|
51
55
|
skill[key] = (skill[key] || 0).to_i + 1
|
|
52
56
|
skill[:updated_at] = Time.now.iso8601(6)
|
|
53
|
-
|
|
57
|
+
write_skill_file(skill_path(user_id, skill_id), stringify_for_json(skill))
|
|
54
58
|
skill
|
|
55
59
|
end
|
|
56
60
|
|
|
@@ -71,10 +75,10 @@ module Llmemory
|
|
|
71
75
|
Array(ids).map(&:to_s).count do |id|
|
|
72
76
|
path = skill_path(user_id, id)
|
|
73
77
|
next false unless File.file?(path)
|
|
74
|
-
data =
|
|
78
|
+
data = load_skill_raw(path)
|
|
75
79
|
next false if data["archived_at"]
|
|
76
80
|
data["archived_at"] = Time.now.iso8601
|
|
77
|
-
|
|
81
|
+
write_skill_file(path, data)
|
|
78
82
|
true
|
|
79
83
|
end
|
|
80
84
|
end
|
|
@@ -103,7 +107,9 @@ module Llmemory
|
|
|
103
107
|
end
|
|
104
108
|
|
|
105
109
|
def load_skill(path)
|
|
106
|
-
data =
|
|
110
|
+
data = load_skill_raw(path)
|
|
111
|
+
return nil unless data
|
|
112
|
+
|
|
107
113
|
data[:created_at] = parse_time(data[:created_at])
|
|
108
114
|
data[:updated_at] = parse_time(data[:updated_at]) if data[:updated_at]
|
|
109
115
|
data
|
|
@@ -111,6 +117,17 @@ module Llmemory
|
|
|
111
117
|
nil
|
|
112
118
|
end
|
|
113
119
|
|
|
120
|
+
def load_skill_raw(path)
|
|
121
|
+
raw = File.read(path)
|
|
122
|
+
json = cipher.enabled? && cipher.encrypted?(raw) ? cipher.decrypt(raw) : raw
|
|
123
|
+
JSON.parse(json, symbolize_names: true)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def write_skill_file(path, data)
|
|
127
|
+
payload = JSON.generate(data)
|
|
128
|
+
File.write(path, cipher.enabled? ? cipher.encrypt(payload) : payload)
|
|
129
|
+
end
|
|
130
|
+
|
|
114
131
|
def skill_text(skill)
|
|
115
132
|
[skill[:name], skill[:description], skill[:body]].compact.join("\n")
|
|
116
133
|
end
|
|
@@ -52,6 +52,8 @@ module Llmemory
|
|
|
52
52
|
stats[:long_term] = { error: e.message }
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
+
stats[:llm_usage] = Llmemory::LLM::UsageLedger.new(store: store).totals(user_id)
|
|
56
|
+
|
|
55
57
|
::MCP::Tool::Response.new([{
|
|
56
58
|
type: "text",
|
|
57
59
|
text: format_stats(stats)
|
|
@@ -102,8 +104,19 @@ module Llmemory
|
|
|
102
104
|
output << " Resources: #{stats[:long_term][:resources]}"
|
|
103
105
|
end
|
|
104
106
|
|
|
107
|
+
output << ""
|
|
108
|
+
output << Llmemory::LLM::UsageLedger.format_text(stats[:llm_usage] || default_llm_usage)
|
|
109
|
+
|
|
105
110
|
output.join("\n")
|
|
106
111
|
end
|
|
112
|
+
|
|
113
|
+
def default_llm_usage
|
|
114
|
+
{
|
|
115
|
+
invoke: { input_tokens: 0, output_tokens: 0, total_tokens: 0, calls: 0 },
|
|
116
|
+
embed: { total_tokens: 0, calls: 0 },
|
|
117
|
+
updated_at: nil
|
|
118
|
+
}
|
|
119
|
+
end
|
|
107
120
|
end
|
|
108
121
|
end
|
|
109
122
|
end
|
data/lib/llmemory/memory.rb
CHANGED
|
@@ -10,47 +10,76 @@ module Llmemory
|
|
|
10
10
|
DEFAULT_SESSION_ID = "default"
|
|
11
11
|
STATE_KEY_MESSAGES = :messages
|
|
12
12
|
|
|
13
|
-
def initialize(user_id:, session_id: DEFAULT_SESSION_ID, checkpoint: nil, long_term: nil, long_term_type: nil, retrieval_engine: nil, working_memory: nil, episodic: nil, procedural: nil, api_key: nil)
|
|
13
|
+
def initialize(user_id:, session_id: DEFAULT_SESSION_ID, checkpoint: nil, long_term: nil, long_term_type: nil, retrieval_engine: nil, working_memory: nil, episodic: nil, procedural: nil, api_key: nil, encryption_key: :inherit)
|
|
14
14
|
@user_id = user_id
|
|
15
15
|
@session_id = session_id
|
|
16
|
-
|
|
16
|
+
resolved_key = encryption_key == :inherit ? nil : encryption_key
|
|
17
|
+
@cipher = Llmemory.build_cipher(resolved_key)
|
|
18
|
+
if checkpoint
|
|
19
|
+
@checkpoint = checkpoint
|
|
20
|
+
@short_term_store = checkpoint.store
|
|
21
|
+
else
|
|
22
|
+
@short_term_store = build_short_term_store(@cipher)
|
|
23
|
+
@checkpoint = ShortTerm::Checkpoint.new(
|
|
24
|
+
user_id: user_id,
|
|
25
|
+
session_id: session_id,
|
|
26
|
+
store: @short_term_store,
|
|
27
|
+
cipher: @cipher
|
|
28
|
+
)
|
|
29
|
+
end
|
|
17
30
|
@working_memory = working_memory
|
|
18
31
|
@episodic = episodic
|
|
19
32
|
@procedural = procedural
|
|
20
|
-
@
|
|
33
|
+
@api_key = api_key unless api_key.to_s.empty?
|
|
21
34
|
type = long_term_type || Llmemory.configuration.long_term_type || :file_based
|
|
22
35
|
@long_term = long_term || build_long_term(type)
|
|
23
|
-
@retrieval_engine = retrieval_engine || Retrieval::Engine.new(
|
|
36
|
+
@retrieval_engine = retrieval_engine || Retrieval::Engine.new(
|
|
37
|
+
@long_term,
|
|
38
|
+
llm: tracked_llm_client,
|
|
39
|
+
feedback: Retrieval::FeedbackStore.new(store: @short_term_store)
|
|
40
|
+
)
|
|
24
41
|
end
|
|
25
42
|
|
|
26
43
|
# Structured working memory for this session (CoALA working memory),
|
|
27
44
|
# parallel to the message checkpoint. Lazily built.
|
|
28
45
|
def working_memory
|
|
29
|
-
@working_memory ||= WorkingMemory.new(
|
|
46
|
+
@working_memory ||= WorkingMemory.new(
|
|
47
|
+
user_id: @user_id,
|
|
48
|
+
session_id: @session_id,
|
|
49
|
+
store: build_short_term_store(@cipher)
|
|
50
|
+
)
|
|
30
51
|
end
|
|
31
52
|
|
|
32
53
|
# Episodic long-term memory (CoALA): records and retrieves agent trajectories.
|
|
33
54
|
# Additive — coexists with the semantic store (file/graph). Lazily built.
|
|
34
55
|
def episodic
|
|
35
|
-
@episodic ||= LongTerm::Episodic::Memory.new(
|
|
56
|
+
@episodic ||= LongTerm::Episodic::Memory.new(
|
|
57
|
+
user_id: @user_id,
|
|
58
|
+
storage: LongTerm::Episodic::Storages.build(cipher: @cipher),
|
|
59
|
+
cipher: @cipher
|
|
60
|
+
)
|
|
36
61
|
end
|
|
37
62
|
|
|
38
63
|
# Procedural long-term memory (Voyager-style skill library). Lazily built.
|
|
39
64
|
def procedural
|
|
40
|
-
@procedural ||= LongTerm::Procedural::Memory.new(
|
|
65
|
+
@procedural ||= LongTerm::Procedural::Memory.new(
|
|
66
|
+
user_id: @user_id,
|
|
67
|
+
storage: LongTerm::Procedural::Storages.build(cipher: @cipher),
|
|
68
|
+
cipher: @cipher
|
|
69
|
+
)
|
|
41
70
|
end
|
|
42
71
|
|
|
43
72
|
# Reflects over recent episodes and writes distilled insights to the
|
|
44
73
|
# semantic store (file/graph) with provenance back to source episodes.
|
|
45
74
|
def reflect!(window: 10, category: "insights")
|
|
46
|
-
Reflection::Reflector.new(episodic: episodic, semantic: @long_term, llm:
|
|
75
|
+
Reflection::Reflector.new(episodic: episodic, semantic: @long_term, llm: tracked_llm_client)
|
|
47
76
|
.reflect(window: window, category: category)
|
|
48
77
|
end
|
|
49
78
|
|
|
50
79
|
# Reasoning action: render a prompt from working memory, call the LLM, write
|
|
51
80
|
# the result back. Composable; does not touch long-term memory.
|
|
52
81
|
def reason(template:, into: Actions::Reason::DEFAULT_SLOT, parse: nil)
|
|
53
|
-
Actions::Reason.call(working_memory: working_memory, template: template, into: into, parse: parse, llm:
|
|
82
|
+
Actions::Reason.call(working_memory: working_memory, template: template, into: into, parse: parse, llm: tracked_llm_client)
|
|
54
83
|
end
|
|
55
84
|
|
|
56
85
|
# Mines recent episodes for reusable skills (Voyager-style). Human-in-the-loop
|
|
@@ -58,7 +87,7 @@ module Llmemory
|
|
|
58
87
|
# `auto_register: true`, registers them in procedural memory (with provenance
|
|
59
88
|
# back to the source episodes) and returns the new skill ids.
|
|
60
89
|
def mine_skills!(window: SkillMining::Miner::DEFAULT_WINDOW, outcomes: nil, auto_register: false)
|
|
61
|
-
SkillMining::Miner.new(episodic: episodic, procedural: procedural, llm:
|
|
90
|
+
SkillMining::Miner.new(episodic: episodic, procedural: procedural, llm: tracked_llm_client)
|
|
62
91
|
.mine(window: window, outcomes: outcomes, auto_register: auto_register)
|
|
63
92
|
end
|
|
64
93
|
|
|
@@ -68,7 +97,7 @@ module Llmemory
|
|
|
68
97
|
def maintain!(**opts)
|
|
69
98
|
Maintenance::CognitivePass.run!(
|
|
70
99
|
@user_id,
|
|
71
|
-
memory: self, episodic: episodic, procedural: procedural, semantic: @long_term, llm:
|
|
100
|
+
memory: self, episodic: episodic, procedural: procedural, semantic: @long_term, llm: tracked_llm_client,
|
|
72
101
|
**opts
|
|
73
102
|
)
|
|
74
103
|
end
|
|
@@ -222,6 +251,10 @@ module Llmemory
|
|
|
222
251
|
@user_id
|
|
223
252
|
end
|
|
224
253
|
|
|
254
|
+
def llm_usage
|
|
255
|
+
Llmemory::LLM::UsageLedger.new(store: @short_term_store).totals(@user_id)
|
|
256
|
+
end
|
|
257
|
+
|
|
225
258
|
private
|
|
226
259
|
|
|
227
260
|
def summarize_messages(msgs)
|
|
@@ -240,7 +273,16 @@ module Llmemory
|
|
|
240
273
|
end
|
|
241
274
|
|
|
242
275
|
def llm_client
|
|
243
|
-
|
|
276
|
+
tracked_llm_client
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def tracked_llm_client
|
|
280
|
+
@tracked_llm_client ||= Llmemory::LLM::TrackingClient.new(
|
|
281
|
+
nil,
|
|
282
|
+
user_id: @user_id,
|
|
283
|
+
store: @short_term_store,
|
|
284
|
+
api_key: @api_key
|
|
285
|
+
)
|
|
244
286
|
end
|
|
245
287
|
|
|
246
288
|
def flush_memory_before_compaction!(msgs)
|
|
@@ -316,19 +358,28 @@ module Llmemory
|
|
|
316
358
|
end
|
|
317
359
|
|
|
318
360
|
def build_long_term(long_term_type)
|
|
319
|
-
llm_opts =
|
|
361
|
+
llm_opts = { llm: tracked_llm_client }
|
|
320
362
|
case long_term_type.to_s.to_sym
|
|
321
363
|
when :graph_based
|
|
322
364
|
LongTerm::GraphBased::Memory.new(
|
|
323
365
|
user_id: @user_id,
|
|
324
|
-
storage: LongTerm::GraphBased::Storages.build,
|
|
366
|
+
storage: LongTerm::GraphBased::Storages.build(cipher: @cipher),
|
|
367
|
+
cipher: @cipher,
|
|
325
368
|
**llm_opts
|
|
326
369
|
)
|
|
327
370
|
else
|
|
328
|
-
LongTerm::FileBased::Memory.new(
|
|
371
|
+
LongTerm::FileBased::Memory.new(
|
|
372
|
+
user_id: @user_id,
|
|
373
|
+
storage: LongTerm::FileBased::Storages.build(cipher: @cipher),
|
|
374
|
+
**llm_opts
|
|
375
|
+
)
|
|
329
376
|
end
|
|
330
377
|
end
|
|
331
378
|
|
|
379
|
+
def build_short_term_store(cipher)
|
|
380
|
+
ShortTerm::Stores.build(cipher: cipher)
|
|
381
|
+
end
|
|
382
|
+
|
|
332
383
|
def save_state(messages:, last_flush_at: nil, last_compact_at: nil)
|
|
333
384
|
state = { STATE_KEY_MESSAGES => messages, last_activity_at: Time.now }
|
|
334
385
|
state[:last_flush_at] = last_flush_at if last_flush_at
|
|
@@ -7,12 +7,15 @@ module Llmemory
|
|
|
7
7
|
class Checkpoint
|
|
8
8
|
DEFAULT_SESSION_ID = "default"
|
|
9
9
|
|
|
10
|
-
def initialize(user_id:, session_id: DEFAULT_SESSION_ID, store: nil)
|
|
10
|
+
def initialize(user_id:, session_id: DEFAULT_SESSION_ID, store: nil, cipher: nil)
|
|
11
11
|
@user_id = user_id
|
|
12
12
|
@session_id = session_id
|
|
13
|
+
@cipher = cipher
|
|
13
14
|
@store = store || build_store
|
|
14
15
|
end
|
|
15
16
|
|
|
17
|
+
attr_reader :store
|
|
18
|
+
|
|
16
19
|
def save_state(state)
|
|
17
20
|
@store.save(@user_id, @session_id, state)
|
|
18
21
|
end
|
|
@@ -28,7 +31,7 @@ module Llmemory
|
|
|
28
31
|
private
|
|
29
32
|
|
|
30
33
|
def build_store
|
|
31
|
-
Stores.build
|
|
34
|
+
Stores.build(cipher: @cipher)
|
|
32
35
|
end
|
|
33
36
|
end
|
|
34
37
|
end
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "base"
|
|
4
|
+
require_relative "../../crypto/field_helpers"
|
|
4
5
|
|
|
5
6
|
module Llmemory
|
|
6
7
|
module ShortTerm
|
|
7
8
|
module Stores
|
|
8
9
|
class ActiveRecordStore < Base
|
|
9
|
-
|
|
10
|
+
include Llmemory::Crypto::FieldHelpers
|
|
11
|
+
|
|
12
|
+
def initialize(cipher: nil)
|
|
13
|
+
@cipher = cipher || Llmemory.build_cipher
|
|
10
14
|
self.class.load_model!
|
|
11
15
|
end
|
|
12
16
|
|
|
@@ -22,7 +26,7 @@ module Llmemory
|
|
|
22
26
|
user_id: user_id,
|
|
23
27
|
session_id: session_id
|
|
24
28
|
)
|
|
25
|
-
record.state = state
|
|
29
|
+
record.state = cipher.enabled? ? serialize_state(state) : state
|
|
26
30
|
record.updated_at = Time.current
|
|
27
31
|
record.save!
|
|
28
32
|
true
|
|
@@ -34,8 +38,13 @@ module Llmemory
|
|
|
34
38
|
session_id: session_id
|
|
35
39
|
)
|
|
36
40
|
return nil unless record
|
|
41
|
+
|
|
37
42
|
raw = record.state
|
|
38
|
-
raw.is_a?(Hash)
|
|
43
|
+
if raw.is_a?(Hash)
|
|
44
|
+
raw.transform_keys(&:to_sym)
|
|
45
|
+
else
|
|
46
|
+
deserialize_state(raw)
|
|
47
|
+
end
|
|
39
48
|
end
|
|
40
49
|
|
|
41
50
|
def delete(user_id, session_id)
|
|
@@ -53,13 +62,6 @@ module Llmemory
|
|
|
53
62
|
def list_sessions(user_id:)
|
|
54
63
|
Llmemory::ShortTerm::Stores::ActiveRecordCheckpoint.where(user_id: user_id).pluck(:session_id)
|
|
55
64
|
end
|
|
56
|
-
|
|
57
|
-
private
|
|
58
|
-
|
|
59
|
-
def deserialize(data)
|
|
60
|
-
return data if data.is_a?(Hash)
|
|
61
|
-
JSON.parse(data.to_s, symbolize_names: true)
|
|
62
|
-
end
|
|
63
65
|
end
|
|
64
66
|
end
|
|
65
67
|
end
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "base"
|
|
4
|
+
require_relative "../../crypto/field_helpers"
|
|
4
5
|
|
|
5
6
|
module Llmemory
|
|
6
7
|
module ShortTerm
|
|
7
8
|
module Stores
|
|
8
9
|
class PostgresStore < Base
|
|
9
|
-
|
|
10
|
+
include Llmemory::Crypto::FieldHelpers
|
|
11
|
+
|
|
12
|
+
def initialize(database_url: nil, cipher: nil)
|
|
10
13
|
@database_url = database_url || Llmemory.configuration.database_url
|
|
11
14
|
@connection = nil
|
|
15
|
+
@cipher = cipher || Llmemory.build_cipher
|
|
12
16
|
end
|
|
13
17
|
|
|
14
18
|
def save(user_id, session_id, state)
|
|
@@ -81,13 +85,15 @@ module Llmemory
|
|
|
81
85
|
end
|
|
82
86
|
|
|
83
87
|
def serialize(state)
|
|
84
|
-
|
|
85
|
-
JSON.generate(
|
|
88
|
+
payload = serialize_state(state)
|
|
89
|
+
cipher.enabled? ? JSON.generate(payload) : payload
|
|
86
90
|
end
|
|
87
91
|
|
|
88
92
|
def deserialize(data)
|
|
89
|
-
|
|
90
|
-
|
|
93
|
+
if data.is_a?(String) && !cipher.encrypted?(data)
|
|
94
|
+
data = JSON.parse(data)
|
|
95
|
+
end
|
|
96
|
+
deserialize_state(data)
|
|
91
97
|
end
|
|
92
98
|
end
|
|
93
99
|
end
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "base"
|
|
4
|
+
require_relative "../../crypto/field_helpers"
|
|
4
5
|
|
|
5
6
|
module Llmemory
|
|
6
7
|
module ShortTerm
|
|
7
8
|
module Stores
|
|
8
9
|
class RedisStore < Base
|
|
9
|
-
|
|
10
|
+
include Llmemory::Crypto::FieldHelpers
|
|
11
|
+
|
|
12
|
+
def initialize(redis_url: nil, cipher: nil)
|
|
10
13
|
@redis_url = redis_url || Llmemory.configuration.redis_url
|
|
11
14
|
@redis = nil
|
|
15
|
+
@cipher = cipher || Llmemory.build_cipher
|
|
12
16
|
end
|
|
13
17
|
|
|
14
18
|
def save(user_id, session_id, state)
|
|
@@ -50,13 +54,11 @@ module Llmemory
|
|
|
50
54
|
end
|
|
51
55
|
|
|
52
56
|
def serialize(state)
|
|
53
|
-
|
|
54
|
-
JSON.generate(state)
|
|
57
|
+
serialize_state(state)
|
|
55
58
|
end
|
|
56
59
|
|
|
57
60
|
def deserialize(data)
|
|
58
|
-
|
|
59
|
-
JSON.parse(data, symbolize_names: true)
|
|
61
|
+
deserialize_state(data)
|
|
60
62
|
end
|
|
61
63
|
end
|
|
62
64
|
end
|
|
@@ -10,16 +10,17 @@ module Llmemory
|
|
|
10
10
|
module Stores
|
|
11
11
|
# Single source of truth for selecting a short-term store backend.
|
|
12
12
|
# Shared by Checkpoint, SessionLifecycle and WorkingMemory.
|
|
13
|
-
def self.build(store_type = nil)
|
|
13
|
+
def self.build(store_type = nil, cipher: nil)
|
|
14
|
+
resolved_cipher = cipher || Llmemory.build_cipher
|
|
14
15
|
case (store_type || Llmemory.configuration.short_term_store).to_sym
|
|
15
|
-
when :memory then MemoryStore.new
|
|
16
|
-
when :redis then RedisStore.new
|
|
17
|
-
when :postgres then PostgresStore.new
|
|
16
|
+
when :memory then MemoryStore.new(cipher: resolved_cipher)
|
|
17
|
+
when :redis then RedisStore.new(cipher: resolved_cipher)
|
|
18
|
+
when :postgres then PostgresStore.new(cipher: resolved_cipher)
|
|
18
19
|
when :active_record, :activerecord
|
|
19
20
|
require_relative "stores/active_record_store"
|
|
20
|
-
ActiveRecordStore.new
|
|
21
|
+
ActiveRecordStore.new(cipher: resolved_cipher)
|
|
21
22
|
else
|
|
22
|
-
MemoryStore.new
|
|
23
|
+
MemoryStore.new(cipher: resolved_cipher)
|
|
23
24
|
end
|
|
24
25
|
end
|
|
25
26
|
end
|
|
@@ -7,10 +7,11 @@ module Llmemory
|
|
|
7
7
|
# Persists embeddings in llmemory_embeddings (pgvector).
|
|
8
8
|
# Use when long_term_store is :active_record so hybrid search finds persisted embeddings.
|
|
9
9
|
class ActiveRecordStore < Base
|
|
10
|
-
def initialize(embedding_provider: nil, source_type: "edge")
|
|
10
|
+
def initialize(embedding_provider: nil, source_type: "edge", cipher: nil)
|
|
11
11
|
self.class.load_model!
|
|
12
12
|
@embedding_provider = embedding_provider
|
|
13
13
|
@source_type = source_type.to_s
|
|
14
|
+
@cipher = cipher || Llmemory.build_cipher
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
def self.load_model!
|
|
@@ -25,6 +26,12 @@ module Llmemory
|
|
|
25
26
|
@embedding_provider.embed(text)
|
|
26
27
|
end
|
|
27
28
|
|
|
29
|
+
def last_usage
|
|
30
|
+
return @embedding_provider.last_usage if @embedding_provider&.respond_to?(:last_usage)
|
|
31
|
+
|
|
32
|
+
Llmemory::LLM::Usage.zero
|
|
33
|
+
end
|
|
34
|
+
|
|
28
35
|
def store(id:, embedding:, metadata: {}, user_id: nil)
|
|
29
36
|
return id if user_id.nil? || user_id.to_s.empty?
|
|
30
37
|
text_content = (metadata || {}).dig("text") || (metadata || {}).dig(:text)
|
|
@@ -34,7 +41,7 @@ module Llmemory
|
|
|
34
41
|
source_id: id.to_s
|
|
35
42
|
)
|
|
36
43
|
rec.embedding = embedding.to_a.map(&:to_f)
|
|
37
|
-
rec.text_content = text_content
|
|
44
|
+
rec.text_content = encrypt_text_content(text_content)
|
|
38
45
|
rec.save!
|
|
39
46
|
id
|
|
40
47
|
end
|
|
@@ -58,7 +65,11 @@ module Llmemory
|
|
|
58
65
|
{
|
|
59
66
|
id: r.source_id,
|
|
60
67
|
score: score,
|
|
61
|
-
metadata: {
|
|
68
|
+
metadata: {
|
|
69
|
+
"text" => decrypt_text_content(r.text_content),
|
|
70
|
+
"created_at" => r.created_at,
|
|
71
|
+
"user_id" => r.user_id
|
|
72
|
+
}
|
|
62
73
|
}
|
|
63
74
|
end
|
|
64
75
|
end
|
|
@@ -69,6 +80,22 @@ module Llmemory
|
|
|
69
80
|
query_embedding = @embedding_provider.embed(query_text)
|
|
70
81
|
search(query_embedding, top_k: top_k, user_id: user_id)
|
|
71
82
|
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def encrypt_text_content(text)
|
|
87
|
+
return text if text.nil? || text.to_s.empty?
|
|
88
|
+
return text unless @cipher.enabled?
|
|
89
|
+
|
|
90
|
+
@cipher.encrypt(text.to_s)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def decrypt_text_content(text)
|
|
94
|
+
return text if text.nil?
|
|
95
|
+
return text unless text.is_a?(String) && @cipher.enabled? && @cipher.encrypted?(text)
|
|
96
|
+
|
|
97
|
+
@cipher.decrypt(text)
|
|
98
|
+
end
|
|
72
99
|
end
|
|
73
100
|
end
|
|
74
101
|
end
|
|
@@ -5,9 +5,10 @@ require_relative "base"
|
|
|
5
5
|
module Llmemory
|
|
6
6
|
module VectorStore
|
|
7
7
|
class MemoryStore < Base
|
|
8
|
-
def initialize(embedding_provider: nil)
|
|
8
|
+
def initialize(embedding_provider: nil, cipher: nil)
|
|
9
9
|
@entries = {}
|
|
10
10
|
@embedding_provider = embedding_provider
|
|
11
|
+
@cipher = cipher || Llmemory.build_cipher
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def embed(text)
|
|
@@ -15,9 +16,21 @@ module Llmemory
|
|
|
15
16
|
@embedding_provider.embed(text)
|
|
16
17
|
end
|
|
17
18
|
|
|
19
|
+
def last_usage
|
|
20
|
+
return @embedding_provider.last_usage if @embedding_provider&.respond_to?(:last_usage)
|
|
21
|
+
|
|
22
|
+
Llmemory::LLM::Usage.zero
|
|
23
|
+
end
|
|
24
|
+
|
|
18
25
|
def store(id:, embedding:, metadata: {}, user_id: nil)
|
|
19
26
|
key = user_id ? "#{user_id}:#{id}" : id.to_s
|
|
20
|
-
|
|
27
|
+
meta = (metadata || {}).dup
|
|
28
|
+
if meta["text"] && @cipher.enabled?
|
|
29
|
+
meta["text"] = @cipher.encrypt(meta["text"].to_s)
|
|
30
|
+
elsif meta[:text] && @cipher.enabled?
|
|
31
|
+
meta[:text] = @cipher.encrypt(meta[:text].to_s)
|
|
32
|
+
end
|
|
33
|
+
@entries[key] = { embedding: embedding.to_a.map(&:to_f), metadata: meta.merge("user_id" => user_id) }
|
|
21
34
|
id
|
|
22
35
|
end
|
|
23
36
|
|
|
@@ -27,7 +40,7 @@ module Llmemory
|
|
|
27
40
|
entries = user_id ? @entries.select { |k, _| k.to_s.start_with?("#{user_id}:") } : @entries
|
|
28
41
|
scores = entries.map do |id, data|
|
|
29
42
|
sim = cosine_similarity(query, data[:embedding])
|
|
30
|
-
{ id: id, score: sim, metadata: data[:metadata] }
|
|
43
|
+
{ id: id, score: sim, metadata: decrypt_metadata(data[:metadata]) }
|
|
31
44
|
end
|
|
32
45
|
scores.sort_by { |s| -s[:score] }.first(top_k)
|
|
33
46
|
end
|
|
@@ -40,6 +53,19 @@ module Llmemory
|
|
|
40
53
|
|
|
41
54
|
private
|
|
42
55
|
|
|
56
|
+
def decrypt_metadata(meta)
|
|
57
|
+
return meta unless meta.is_a?(Hash) && @cipher.enabled?
|
|
58
|
+
|
|
59
|
+
out = meta.dup
|
|
60
|
+
text = out["text"] || out[:text]
|
|
61
|
+
if text.is_a?(String) && @cipher.encrypted?(text)
|
|
62
|
+
decrypted = @cipher.decrypt(text)
|
|
63
|
+
out["text"] = decrypted
|
|
64
|
+
out[:text] = decrypted
|
|
65
|
+
end
|
|
66
|
+
out
|
|
67
|
+
end
|
|
68
|
+
|
|
43
69
|
def cosine_similarity(a, b)
|
|
44
70
|
return 0.0 if a.size != b.size || a.empty?
|
|
45
71
|
dot = a.zip(b).sum { |x, y| x * y }
|