llmemory 0.2.2 → 0.2.3
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 +31 -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/llm/anthropic.rb +2 -1
- data/lib/llmemory/llm/openai.rb +2 -1
- data/lib/llmemory/long_term/episodic/memory.rb +4 -3
- 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 +4 -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 +4 -3
- 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/memory.rb +40 -8
- data/lib/llmemory/short_term/checkpoint.rb +3 -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 +24 -3
- data/lib/llmemory/vector_store/memory_store.rb +23 -3
- data/lib/llmemory/vector_store.rb +4 -3
- data/lib/llmemory/version.rb +1 -1
- data/lib/llmemory.rb +2 -0
- metadata +3 -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
|
data/lib/llmemory/memory.rb
CHANGED
|
@@ -10,34 +10,57 @@ 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
|
+
@checkpoint = checkpoint || ShortTerm::Checkpoint.new(
|
|
19
|
+
user_id: user_id,
|
|
20
|
+
session_id: session_id,
|
|
21
|
+
cipher: @cipher
|
|
22
|
+
)
|
|
17
23
|
@working_memory = working_memory
|
|
18
24
|
@episodic = episodic
|
|
19
25
|
@procedural = procedural
|
|
20
26
|
@llm = api_key.to_s.empty? ? nil : Llmemory::LLM.client(api_key: api_key)
|
|
21
27
|
type = long_term_type || Llmemory.configuration.long_term_type || :file_based
|
|
22
28
|
@long_term = long_term || build_long_term(type)
|
|
23
|
-
|
|
29
|
+
short_term_store = build_short_term_store(@cipher)
|
|
30
|
+
@retrieval_engine = retrieval_engine || Retrieval::Engine.new(
|
|
31
|
+
@long_term,
|
|
32
|
+
llm: @llm,
|
|
33
|
+
feedback: Retrieval::FeedbackStore.new(store: short_term_store)
|
|
34
|
+
)
|
|
24
35
|
end
|
|
25
36
|
|
|
26
37
|
# Structured working memory for this session (CoALA working memory),
|
|
27
38
|
# parallel to the message checkpoint. Lazily built.
|
|
28
39
|
def working_memory
|
|
29
|
-
@working_memory ||= WorkingMemory.new(
|
|
40
|
+
@working_memory ||= WorkingMemory.new(
|
|
41
|
+
user_id: @user_id,
|
|
42
|
+
session_id: @session_id,
|
|
43
|
+
store: build_short_term_store(@cipher)
|
|
44
|
+
)
|
|
30
45
|
end
|
|
31
46
|
|
|
32
47
|
# Episodic long-term memory (CoALA): records and retrieves agent trajectories.
|
|
33
48
|
# Additive — coexists with the semantic store (file/graph). Lazily built.
|
|
34
49
|
def episodic
|
|
35
|
-
@episodic ||= LongTerm::Episodic::Memory.new(
|
|
50
|
+
@episodic ||= LongTerm::Episodic::Memory.new(
|
|
51
|
+
user_id: @user_id,
|
|
52
|
+
storage: LongTerm::Episodic::Storages.build(cipher: @cipher),
|
|
53
|
+
cipher: @cipher
|
|
54
|
+
)
|
|
36
55
|
end
|
|
37
56
|
|
|
38
57
|
# Procedural long-term memory (Voyager-style skill library). Lazily built.
|
|
39
58
|
def procedural
|
|
40
|
-
@procedural ||= LongTerm::Procedural::Memory.new(
|
|
59
|
+
@procedural ||= LongTerm::Procedural::Memory.new(
|
|
60
|
+
user_id: @user_id,
|
|
61
|
+
storage: LongTerm::Procedural::Storages.build(cipher: @cipher),
|
|
62
|
+
cipher: @cipher
|
|
63
|
+
)
|
|
41
64
|
end
|
|
42
65
|
|
|
43
66
|
# Reflects over recent episodes and writes distilled insights to the
|
|
@@ -321,14 +344,23 @@ module Llmemory
|
|
|
321
344
|
when :graph_based
|
|
322
345
|
LongTerm::GraphBased::Memory.new(
|
|
323
346
|
user_id: @user_id,
|
|
324
|
-
storage: LongTerm::GraphBased::Storages.build,
|
|
347
|
+
storage: LongTerm::GraphBased::Storages.build(cipher: @cipher),
|
|
348
|
+
cipher: @cipher,
|
|
325
349
|
**llm_opts
|
|
326
350
|
)
|
|
327
351
|
else
|
|
328
|
-
LongTerm::FileBased::Memory.new(
|
|
352
|
+
LongTerm::FileBased::Memory.new(
|
|
353
|
+
user_id: @user_id,
|
|
354
|
+
storage: LongTerm::FileBased::Storages.build(cipher: @cipher),
|
|
355
|
+
**llm_opts
|
|
356
|
+
)
|
|
329
357
|
end
|
|
330
358
|
end
|
|
331
359
|
|
|
360
|
+
def build_short_term_store(cipher)
|
|
361
|
+
ShortTerm::Stores.build(cipher: cipher)
|
|
362
|
+
end
|
|
363
|
+
|
|
332
364
|
def save_state(messages:, last_flush_at: nil, last_compact_at: nil)
|
|
333
365
|
state = { STATE_KEY_MESSAGES => messages, last_activity_at: Time.now }
|
|
334
366
|
state[:last_flush_at] = last_flush_at if last_flush_at
|
|
@@ -7,9 +7,10 @@ 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
|
|
|
@@ -28,7 +29,7 @@ module Llmemory
|
|
|
28
29
|
private
|
|
29
30
|
|
|
30
31
|
def build_store
|
|
31
|
-
Stores.build
|
|
32
|
+
Stores.build(cipher: @cipher)
|
|
32
33
|
end
|
|
33
34
|
end
|
|
34
35
|
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!
|
|
@@ -34,7 +35,7 @@ module Llmemory
|
|
|
34
35
|
source_id: id.to_s
|
|
35
36
|
)
|
|
36
37
|
rec.embedding = embedding.to_a.map(&:to_f)
|
|
37
|
-
rec.text_content = text_content
|
|
38
|
+
rec.text_content = encrypt_text_content(text_content)
|
|
38
39
|
rec.save!
|
|
39
40
|
id
|
|
40
41
|
end
|
|
@@ -58,7 +59,11 @@ module Llmemory
|
|
|
58
59
|
{
|
|
59
60
|
id: r.source_id,
|
|
60
61
|
score: score,
|
|
61
|
-
metadata: {
|
|
62
|
+
metadata: {
|
|
63
|
+
"text" => decrypt_text_content(r.text_content),
|
|
64
|
+
"created_at" => r.created_at,
|
|
65
|
+
"user_id" => r.user_id
|
|
66
|
+
}
|
|
62
67
|
}
|
|
63
68
|
end
|
|
64
69
|
end
|
|
@@ -69,6 +74,22 @@ module Llmemory
|
|
|
69
74
|
query_embedding = @embedding_provider.embed(query_text)
|
|
70
75
|
search(query_embedding, top_k: top_k, user_id: user_id)
|
|
71
76
|
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def encrypt_text_content(text)
|
|
81
|
+
return text if text.nil? || text.to_s.empty?
|
|
82
|
+
return text unless @cipher.enabled?
|
|
83
|
+
|
|
84
|
+
@cipher.encrypt(text.to_s)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def decrypt_text_content(text)
|
|
88
|
+
return text if text.nil?
|
|
89
|
+
return text unless text.is_a?(String) && @cipher.enabled? && @cipher.encrypted?(text)
|
|
90
|
+
|
|
91
|
+
@cipher.decrypt(text)
|
|
92
|
+
end
|
|
72
93
|
end
|
|
73
94
|
end
|
|
74
95
|
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)
|
|
@@ -17,7 +18,13 @@ module Llmemory
|
|
|
17
18
|
|
|
18
19
|
def store(id:, embedding:, metadata: {}, user_id: nil)
|
|
19
20
|
key = user_id ? "#{user_id}:#{id}" : id.to_s
|
|
20
|
-
|
|
21
|
+
meta = (metadata || {}).dup
|
|
22
|
+
if meta["text"] && @cipher.enabled?
|
|
23
|
+
meta["text"] = @cipher.encrypt(meta["text"].to_s)
|
|
24
|
+
elsif meta[:text] && @cipher.enabled?
|
|
25
|
+
meta[:text] = @cipher.encrypt(meta[:text].to_s)
|
|
26
|
+
end
|
|
27
|
+
@entries[key] = { embedding: embedding.to_a.map(&:to_f), metadata: meta.merge("user_id" => user_id) }
|
|
21
28
|
id
|
|
22
29
|
end
|
|
23
30
|
|
|
@@ -27,7 +34,7 @@ module Llmemory
|
|
|
27
34
|
entries = user_id ? @entries.select { |k, _| k.to_s.start_with?("#{user_id}:") } : @entries
|
|
28
35
|
scores = entries.map do |id, data|
|
|
29
36
|
sim = cosine_similarity(query, data[:embedding])
|
|
30
|
-
{ id: id, score: sim, metadata: data[:metadata] }
|
|
37
|
+
{ id: id, score: sim, metadata: decrypt_metadata(data[:metadata]) }
|
|
31
38
|
end
|
|
32
39
|
scores.sort_by { |s| -s[:score] }.first(top_k)
|
|
33
40
|
end
|
|
@@ -40,6 +47,19 @@ module Llmemory
|
|
|
40
47
|
|
|
41
48
|
private
|
|
42
49
|
|
|
50
|
+
def decrypt_metadata(meta)
|
|
51
|
+
return meta unless meta.is_a?(Hash) && @cipher.enabled?
|
|
52
|
+
|
|
53
|
+
out = meta.dup
|
|
54
|
+
text = out["text"] || out[:text]
|
|
55
|
+
if text.is_a?(String) && @cipher.encrypted?(text)
|
|
56
|
+
decrypted = @cipher.decrypt(text)
|
|
57
|
+
out["text"] = decrypted
|
|
58
|
+
out[:text] = decrypted
|
|
59
|
+
end
|
|
60
|
+
out
|
|
61
|
+
end
|
|
62
|
+
|
|
43
63
|
def cosine_similarity(a, b)
|
|
44
64
|
return 0.0 if a.size != b.size || a.empty?
|
|
45
65
|
dot = a.zip(b).sum { |x, y| x * y }
|
|
@@ -10,14 +10,15 @@ module Llmemory
|
|
|
10
10
|
# from config (:active_record persists in llmemory_embeddings; otherwise
|
|
11
11
|
# in-process). `source_type` namespaces persisted embeddings so different
|
|
12
12
|
# memory types (edges, episodes, skills) never collide in the shared table.
|
|
13
|
-
def self.build(source_type: "edge")
|
|
13
|
+
def self.build(source_type: "edge", cipher: nil)
|
|
14
|
+
resolved_cipher = cipher || Llmemory.build_cipher
|
|
14
15
|
embeddings = OpenAIEmbeddings.new
|
|
15
16
|
store_type = (Llmemory.configuration.long_term_store || :memory).to_s.to_sym
|
|
16
17
|
if store_type == :active_record || store_type == :activerecord
|
|
17
18
|
require_relative "vector_store/active_record_store"
|
|
18
|
-
ActiveRecordStore.new(embedding_provider: embeddings, source_type: source_type)
|
|
19
|
+
ActiveRecordStore.new(embedding_provider: embeddings, source_type: source_type, cipher: resolved_cipher)
|
|
19
20
|
else
|
|
20
|
-
MemoryStore.new(embedding_provider: embeddings)
|
|
21
|
+
MemoryStore.new(embedding_provider: embeddings, cipher: resolved_cipher)
|
|
21
22
|
end
|
|
22
23
|
end
|
|
23
24
|
end
|
data/lib/llmemory/version.rb
CHANGED
data/lib/llmemory.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: llmemory
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- llmemory
|
|
@@ -162,6 +162,8 @@ files:
|
|
|
162
162
|
- lib/llmemory/cli/commands/users.rb
|
|
163
163
|
- lib/llmemory/cli/commands/working.rb
|
|
164
164
|
- lib/llmemory/configuration.rb
|
|
165
|
+
- lib/llmemory/crypto/cipher.rb
|
|
166
|
+
- lib/llmemory/crypto/field_helpers.rb
|
|
165
167
|
- lib/llmemory/dashboard.rb
|
|
166
168
|
- lib/llmemory/dashboard/engine.rb
|
|
167
169
|
- lib/llmemory/extractors.rb
|