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
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module LLM
|
|
5
|
+
class Usage
|
|
6
|
+
attr_reader :input_tokens, :output_tokens, :total_tokens
|
|
7
|
+
|
|
8
|
+
def initialize(input_tokens:, output_tokens:, total_tokens: nil)
|
|
9
|
+
@input_tokens = input_tokens.to_i
|
|
10
|
+
@output_tokens = output_tokens.to_i
|
|
11
|
+
@total_tokens = total_tokens.nil? ? (@input_tokens + @output_tokens) : total_tokens.to_i
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.zero
|
|
15
|
+
new(input_tokens: 0, output_tokens: 0, total_tokens: 0)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def +(other)
|
|
19
|
+
self.class.new(
|
|
20
|
+
input_tokens: @input_tokens + other.input_tokens,
|
|
21
|
+
output_tokens: @output_tokens + other.output_tokens,
|
|
22
|
+
total_tokens: @total_tokens + other.total_tokens
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_h
|
|
27
|
+
{ input_tokens: @input_tokens, output_tokens: @output_tokens, total_tokens: @total_tokens }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "../short_term/stores"
|
|
5
|
+
|
|
6
|
+
module Llmemory
|
|
7
|
+
module LLM
|
|
8
|
+
# Cumulative LLM token usage per user, persisted in the short-term store
|
|
9
|
+
# under a pseudo-session key (same pattern as ForgetLog).
|
|
10
|
+
class UsageLedger
|
|
11
|
+
SESSION_KEY = "__llm_usage__"
|
|
12
|
+
|
|
13
|
+
def initialize(store: nil)
|
|
14
|
+
@store = store || ShortTerm::Stores.build
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def record(user_id, usage, operation:)
|
|
18
|
+
state = load_raw(user_id)
|
|
19
|
+
case operation.to_sym
|
|
20
|
+
when :invoke
|
|
21
|
+
bucket = symbolize_bucket(state[:invoke] || state["invoke"])
|
|
22
|
+
state = state.merge(
|
|
23
|
+
invoke: {
|
|
24
|
+
input_tokens: bucket[:input_tokens] + usage.input_tokens,
|
|
25
|
+
output_tokens: bucket[:output_tokens] + usage.output_tokens,
|
|
26
|
+
total_tokens: bucket[:total_tokens] + usage.total_tokens,
|
|
27
|
+
calls: bucket[:calls] + 1
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
when :embed
|
|
31
|
+
bucket = symbolize_bucket(state[:embed] || state["embed"], embed: true)
|
|
32
|
+
state = state.merge(
|
|
33
|
+
embed: {
|
|
34
|
+
total_tokens: bucket[:total_tokens] + usage.total_tokens,
|
|
35
|
+
calls: bucket[:calls] + 1
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
else
|
|
39
|
+
return totals(user_id)
|
|
40
|
+
end
|
|
41
|
+
state[:updated_at] = Time.now.iso8601
|
|
42
|
+
@store.save(user_id, SESSION_KEY, stringify(state))
|
|
43
|
+
totals(user_id)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def totals(user_id)
|
|
47
|
+
normalize(load_raw(user_id))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def reset!(user_id)
|
|
51
|
+
empty = default_state
|
|
52
|
+
@store.save(user_id, SESSION_KEY, stringify(empty))
|
|
53
|
+
empty
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.format_text(totals)
|
|
57
|
+
inv = totals[:invoke]
|
|
58
|
+
emb = totals[:embed]
|
|
59
|
+
lines = [
|
|
60
|
+
"LLM TOKEN USAGE:",
|
|
61
|
+
" Chat/completions: #{inv[:total_tokens]} total (#{inv[:input_tokens]} in, #{inv[:output_tokens]} out, #{inv[:calls]} calls)",
|
|
62
|
+
" Embeddings: #{emb[:total_tokens]} total (#{emb[:calls]} calls)"
|
|
63
|
+
]
|
|
64
|
+
lines << " Last updated: #{totals[:updated_at]}" if totals[:updated_at]
|
|
65
|
+
lines.join("\n")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def load_raw(user_id)
|
|
71
|
+
state = @store.load(user_id, SESSION_KEY)
|
|
72
|
+
return default_state unless state.is_a?(Hash)
|
|
73
|
+
normalize(state)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def default_state
|
|
77
|
+
{
|
|
78
|
+
invoke: { input_tokens: 0, output_tokens: 0, total_tokens: 0, calls: 0 },
|
|
79
|
+
embed: { total_tokens: 0, calls: 0 },
|
|
80
|
+
updated_at: nil
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def normalize(state)
|
|
85
|
+
invoke = symbolize_bucket(state[:invoke] || state["invoke"])
|
|
86
|
+
embed = symbolize_bucket(state[:embed] || state["embed"], embed: true)
|
|
87
|
+
{
|
|
88
|
+
invoke: invoke,
|
|
89
|
+
embed: embed,
|
|
90
|
+
updated_at: state[:updated_at] || state["updated_at"]
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def symbolize_bucket(bucket, embed: false)
|
|
95
|
+
bucket = {} unless bucket.is_a?(Hash)
|
|
96
|
+
if embed
|
|
97
|
+
{
|
|
98
|
+
total_tokens: (bucket[:total_tokens] || bucket["total_tokens"] || 0).to_i,
|
|
99
|
+
calls: (bucket[:calls] || bucket["calls"] || 0).to_i
|
|
100
|
+
}
|
|
101
|
+
else
|
|
102
|
+
{
|
|
103
|
+
input_tokens: (bucket[:input_tokens] || bucket["input_tokens"] || 0).to_i,
|
|
104
|
+
output_tokens: (bucket[:output_tokens] || bucket["output_tokens"] || 0).to_i,
|
|
105
|
+
total_tokens: (bucket[:total_tokens] || bucket["total_tokens"] || 0).to_i,
|
|
106
|
+
calls: (bucket[:calls] || bucket["calls"] || 0).to_i
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def stringify(state)
|
|
112
|
+
state.transform_keys(&:to_s).transform_values do |v|
|
|
113
|
+
v.is_a?(Hash) ? v.transform_keys(&:to_s) : v
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "usage_ledger"
|
|
4
|
+
|
|
5
|
+
module Llmemory
|
|
6
|
+
module LLM
|
|
7
|
+
module UsageRecorder
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def record(user_id:, usage:, operation:, store: nil)
|
|
11
|
+
return if user_id.nil? || user_id.to_s.empty?
|
|
12
|
+
return if usage.nil?
|
|
13
|
+
|
|
14
|
+
UsageLedger.new(store: store).record(user_id, usage, operation: operation)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def record_embed_from_store(user_id:, vector_store:, store: nil)
|
|
18
|
+
usage = embed_usage_from(vector_store)
|
|
19
|
+
return unless usage
|
|
20
|
+
|
|
21
|
+
record(user_id: user_id, usage: usage, operation: :embed, store: store)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def embed_usage_from(vector_store)
|
|
25
|
+
return nil unless vector_store
|
|
26
|
+
|
|
27
|
+
if vector_store.respond_to?(:last_usage)
|
|
28
|
+
usage = vector_store.last_usage
|
|
29
|
+
return usage unless usage.nil?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
provider = vector_store.instance_variable_get(:@embedding_provider) if vector_store.instance_variable_defined?(:@embedding_provider)
|
|
33
|
+
provider&.last_usage if provider&.respond_to?(:last_usage)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
data/lib/llmemory/llm.rb
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "llm/base"
|
|
4
|
+
require_relative "llm/usage"
|
|
5
|
+
require_relative "llm/response"
|
|
6
|
+
require_relative "llm/usage_ledger"
|
|
7
|
+
require_relative "llm/usage_recorder"
|
|
8
|
+
require_relative "llm/tracking_client"
|
|
4
9
|
require_relative "llm/openai"
|
|
5
10
|
require_relative "llm/anthropic"
|
|
6
11
|
|
|
@@ -21,9 +21,10 @@ module Llmemory
|
|
|
21
21
|
|
|
22
22
|
attr_reader :user_id, :storage
|
|
23
23
|
|
|
24
|
-
def initialize(user_id:, storage: nil, vector_store: nil)
|
|
24
|
+
def initialize(user_id:, storage: nil, vector_store: nil, cipher: nil)
|
|
25
25
|
@user_id = user_id
|
|
26
|
-
@
|
|
26
|
+
@cipher = cipher || Llmemory.build_cipher
|
|
27
|
+
@storage = storage || Storages.build(cipher: @cipher)
|
|
27
28
|
@vector_store = vector_store
|
|
28
29
|
@vector_explicit = !vector_store.nil?
|
|
29
30
|
end
|
|
@@ -125,7 +126,7 @@ module Llmemory
|
|
|
125
126
|
if @vector_explicit
|
|
126
127
|
@vector_store
|
|
127
128
|
elsif Llmemory.configuration.episodic_vector_enabled
|
|
128
|
-
@vector_store ||= Llmemory::VectorStore.build(source_type: "episode")
|
|
129
|
+
@vector_store ||= Llmemory::VectorStore.build(source_type: "episode", cipher: @cipher)
|
|
129
130
|
end
|
|
130
131
|
end
|
|
131
132
|
|
|
@@ -134,6 +135,7 @@ module Llmemory
|
|
|
134
135
|
vs = vector_store
|
|
135
136
|
return if vs.nil? || text.to_s.strip.empty?
|
|
136
137
|
embedding = vs.embed(text)
|
|
138
|
+
record_embed_usage(vs)
|
|
137
139
|
return unless embedding
|
|
138
140
|
vs.store(id: id, embedding: embedding, metadata: { text: text, created_at: Time.now }, user_id: @user_id)
|
|
139
141
|
rescue StandardError
|
|
@@ -141,7 +143,9 @@ module Llmemory
|
|
|
141
143
|
end
|
|
142
144
|
|
|
143
145
|
def vector_candidates(query, top_k, vs)
|
|
144
|
-
vs.search_by_text(query.to_s, top_k: top_k, user_id: @user_id)
|
|
146
|
+
results = vs.search_by_text(query.to_s, top_k: top_k, user_id: @user_id)
|
|
147
|
+
record_embed_usage(vs)
|
|
148
|
+
results.filter_map do |r|
|
|
145
149
|
raw = @storage.get_episode(@user_id, r[:id] || r["id"])
|
|
146
150
|
raw && candidate_for(raw, (r[:score] || r["score"] || 1.0).to_f)
|
|
147
151
|
end
|
|
@@ -182,6 +186,14 @@ module Llmemory
|
|
|
182
186
|
return nil if actions.empty?
|
|
183
187
|
"Episode with #{normalized.size} step(s): #{actions.join(' -> ')}"
|
|
184
188
|
end
|
|
189
|
+
|
|
190
|
+
def record_embed_usage(vector_store)
|
|
191
|
+
Llmemory::LLM::UsageRecorder.record_embed_from_store(
|
|
192
|
+
user_id: @user_id,
|
|
193
|
+
vector_store: vector_store,
|
|
194
|
+
store: Llmemory::ShortTerm::Stores.build(cipher: @cipher)
|
|
195
|
+
)
|
|
196
|
+
end
|
|
185
197
|
end
|
|
186
198
|
end
|
|
187
199
|
end
|
|
@@ -12,17 +12,24 @@ module Llmemory
|
|
|
12
12
|
Storage = Storages::MemoryStorage
|
|
13
13
|
|
|
14
14
|
module Storages
|
|
15
|
-
def self.build(store: nil, base_path: nil, database_url: nil)
|
|
15
|
+
def self.build(store: nil, base_path: nil, database_url: nil, cipher: nil)
|
|
16
|
+
resolved_cipher = cipher || Llmemory.build_cipher
|
|
16
17
|
case (store || Llmemory.configuration.long_term_store).to_s.to_sym
|
|
17
18
|
when :memory
|
|
18
19
|
MemoryStorage.new
|
|
19
20
|
when :file
|
|
20
|
-
FileStorage.new(
|
|
21
|
+
FileStorage.new(
|
|
22
|
+
base_path: base_path || Llmemory.configuration.long_term_storage_path,
|
|
23
|
+
cipher: resolved_cipher
|
|
24
|
+
)
|
|
21
25
|
when :postgres, :database
|
|
22
|
-
DatabaseStorage.new(
|
|
26
|
+
DatabaseStorage.new(
|
|
27
|
+
database_url: database_url || Llmemory.configuration.database_url,
|
|
28
|
+
cipher: resolved_cipher
|
|
29
|
+
)
|
|
23
30
|
when :active_record, :activerecord
|
|
24
31
|
require_relative "storages/active_record_storage"
|
|
25
|
-
ActiveRecordStorage.new
|
|
32
|
+
ActiveRecordStorage.new(cipher: resolved_cipher)
|
|
26
33
|
else
|
|
27
34
|
MemoryStorage.new
|
|
28
35
|
end
|
|
@@ -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,7 +14,10 @@ module Llmemory
|
|
|
13
14
|
# AR auto-deserializes jsonb to a Hash (string keys), which Episode.from_h
|
|
14
15
|
# handles. Mirrors the file-based ActiveRecordStorage pattern.
|
|
15
16
|
class ActiveRecordStorage < Base
|
|
16
|
-
|
|
17
|
+
include Llmemory::Crypto::FieldHelpers
|
|
18
|
+
|
|
19
|
+
def initialize(cipher: nil)
|
|
20
|
+
@cipher = cipher || Llmemory.build_cipher
|
|
17
21
|
self.class.load_models!
|
|
18
22
|
end
|
|
19
23
|
|
|
@@ -30,8 +34,8 @@ module Llmemory
|
|
|
30
34
|
data["created_at"] ||= Time.now.utc.iso8601
|
|
31
35
|
rec = LlmemoryEpisode.find_or_initialize_by(id: id)
|
|
32
36
|
rec.user_id = user_id
|
|
33
|
-
rec.data = data
|
|
34
|
-
rec.search_text = searchable_text(data)
|
|
37
|
+
rec.data = cipher.enabled? ? enc_json(data) : data
|
|
38
|
+
rec.search_text = enc(searchable_text(data))
|
|
35
39
|
rec.created_at ||= Time.current
|
|
36
40
|
rec.save!
|
|
37
41
|
id
|
|
@@ -39,19 +43,21 @@ module Llmemory
|
|
|
39
43
|
|
|
40
44
|
def get_episode(user_id, id)
|
|
41
45
|
rec = LlmemoryEpisode.find_by(user_id: user_id, id: id)
|
|
42
|
-
rec
|
|
46
|
+
return nil unless rec
|
|
47
|
+
|
|
48
|
+
decode_data(rec.data)
|
|
43
49
|
end
|
|
44
50
|
|
|
45
51
|
def list_episodes(user_id, limit: nil, offset: nil)
|
|
46
52
|
scope = LlmemoryEpisode.where(user_id: user_id, archived_at: nil).order(created_at: :desc)
|
|
47
53
|
scope = scope.limit(limit) if limit && limit.to_i.positive?
|
|
48
54
|
scope = scope.offset(offset) if offset && offset.to_i.positive?
|
|
49
|
-
scope.map(
|
|
55
|
+
scope.map { |r| decode_data(r.data) }
|
|
50
56
|
end
|
|
51
57
|
|
|
52
58
|
def search_episodes(user_id, query)
|
|
53
59
|
token_scope(LlmemoryEpisode.where(user_id: user_id, archived_at: nil), "search_text", query)
|
|
54
|
-
.order(created_at: :desc).map(
|
|
60
|
+
.order(created_at: :desc).map { |r| decode_data(r.data) }
|
|
55
61
|
end
|
|
56
62
|
|
|
57
63
|
def count_episodes(user_id)
|
|
@@ -96,6 +102,13 @@ module Llmemory
|
|
|
96
102
|
end
|
|
97
103
|
parts.compact.join("\n")
|
|
98
104
|
end
|
|
105
|
+
|
|
106
|
+
def decode_data(raw)
|
|
107
|
+
return raw.transform_keys(&:to_sym) if raw.is_a?(Hash)
|
|
108
|
+
return dec_json(raw) if raw.is_a?(String) && cipher.encrypted?(raw)
|
|
109
|
+
|
|
110
|
+
raw
|
|
111
|
+
end
|
|
99
112
|
end
|
|
100
113
|
end
|
|
101
114
|
end
|
|
@@ -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 for keyword
|
|
14
15
|
# search), mirroring 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_episode(user_id, episode)
|
|
@@ -23,11 +27,12 @@ module Llmemory
|
|
|
23
27
|
id = episode[:id] || episode["id"] || "ep_#{SecureRandom.hex(8)}"
|
|
24
28
|
data = symbolize(episode).merge(id: id, user_id: user_id)
|
|
25
29
|
data[:created_at] ||= Time.now.utc.iso8601
|
|
30
|
+
search = searchable_text(data)
|
|
26
31
|
conn.exec_params(
|
|
27
32
|
"INSERT INTO llmemory_episodes (id, user_id, data, search_text, created_at) " \
|
|
28
33
|
"VALUES ($1, $2, $3::jsonb, $4, $5) " \
|
|
29
34
|
"ON CONFLICT (id) DO UPDATE SET data = $3::jsonb, search_text = $4",
|
|
30
|
-
[id, user_id,
|
|
35
|
+
[id, user_id, store_data(data), enc(search), created_at_value(data)]
|
|
31
36
|
)
|
|
32
37
|
id
|
|
33
38
|
end
|
|
@@ -124,11 +129,28 @@ module Llmemory
|
|
|
124
129
|
end
|
|
125
130
|
|
|
126
131
|
def parse_data(value)
|
|
127
|
-
|
|
132
|
+
if value.is_a?(Hash)
|
|
133
|
+
return value.transform_keys(&:to_sym)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
str = value.to_s
|
|
137
|
+
if cipher.encrypted?(str)
|
|
138
|
+
cipher.decrypt_json(str)
|
|
139
|
+
else
|
|
140
|
+
JSON.parse(str, symbolize_names: true)
|
|
141
|
+
end
|
|
128
142
|
rescue JSON::ParserError
|
|
129
143
|
{}
|
|
130
144
|
end
|
|
131
145
|
|
|
146
|
+
def store_data(data)
|
|
147
|
+
if cipher.enabled?
|
|
148
|
+
JSON.generate(enc_json(data))
|
|
149
|
+
else
|
|
150
|
+
JSON.generate(data)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
132
154
|
def symbolize(hash)
|
|
133
155
|
hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
134
156
|
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 Episodic
|
|
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_episode(user_id, episode)
|
|
19
23
|
id = episode[:id] || episode["id"] || "ep_#{next_seq(user_id)}"
|
|
20
24
|
data = stringify_for_json(episode).merge("id" => id, "user_id" => user_id)
|
|
21
25
|
data["created_at"] ||= Time.now.iso8601
|
|
22
|
-
|
|
26
|
+
write_episode_file(episode_path(user_id, id), data)
|
|
23
27
|
id
|
|
24
28
|
end
|
|
25
29
|
|
|
@@ -57,10 +61,10 @@ module Llmemory
|
|
|
57
61
|
Array(ids).map(&:to_s).count do |id|
|
|
58
62
|
path = episode_path(user_id, id)
|
|
59
63
|
next false unless File.file?(path)
|
|
60
|
-
data =
|
|
64
|
+
data = load_episode_raw(path)
|
|
61
65
|
next false if data["archived_at"]
|
|
62
66
|
data["archived_at"] = Time.now.iso8601
|
|
63
|
-
|
|
67
|
+
write_episode_file(path, data)
|
|
64
68
|
true
|
|
65
69
|
end
|
|
66
70
|
end
|
|
@@ -89,13 +93,26 @@ module Llmemory
|
|
|
89
93
|
end
|
|
90
94
|
|
|
91
95
|
def load_episode(path)
|
|
92
|
-
data =
|
|
96
|
+
data = load_episode_raw(path)
|
|
97
|
+
return nil unless data
|
|
98
|
+
|
|
93
99
|
data[:created_at] = parse_time(data[:created_at])
|
|
94
100
|
data
|
|
95
101
|
rescue JSON::ParserError
|
|
96
102
|
nil
|
|
97
103
|
end
|
|
98
104
|
|
|
105
|
+
def load_episode_raw(path)
|
|
106
|
+
raw = File.read(path)
|
|
107
|
+
json = cipher.enabled? && cipher.encrypted?(raw) ? cipher.decrypt(raw) : raw
|
|
108
|
+
JSON.parse(json, symbolize_names: true)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def write_episode_file(path, data)
|
|
112
|
+
payload = JSON.generate(data)
|
|
113
|
+
File.write(path, cipher.enabled? ? cipher.encrypt(payload) : payload)
|
|
114
|
+
end
|
|
115
|
+
|
|
99
116
|
def episode_text(episode)
|
|
100
117
|
parts = [episode[:summary], episode[:outcome]]
|
|
101
118
|
Array(episode[:steps]).each do |s|
|
|
@@ -14,17 +14,24 @@ module Llmemory
|
|
|
14
14
|
Storage = Storages::MemoryStorage
|
|
15
15
|
|
|
16
16
|
module Storages
|
|
17
|
-
def self.build(store: nil, base_path: nil, database_url: nil)
|
|
17
|
+
def self.build(store: nil, base_path: nil, database_url: nil, cipher: nil)
|
|
18
|
+
resolved_cipher = cipher || Llmemory.build_cipher
|
|
18
19
|
case (store || Llmemory.configuration.long_term_store).to_s.to_sym
|
|
19
20
|
when :memory
|
|
20
21
|
MemoryStorage.new
|
|
21
22
|
when :file
|
|
22
|
-
FileStorage.new(
|
|
23
|
+
FileStorage.new(
|
|
24
|
+
base_path: base_path || Llmemory.configuration.long_term_storage_path,
|
|
25
|
+
cipher: resolved_cipher
|
|
26
|
+
)
|
|
23
27
|
when :postgres, :database
|
|
24
|
-
DatabaseStorage.new(
|
|
28
|
+
DatabaseStorage.new(
|
|
29
|
+
database_url: database_url || Llmemory.configuration.database_url,
|
|
30
|
+
cipher: resolved_cipher
|
|
31
|
+
)
|
|
25
32
|
when :active_record, :activerecord
|
|
26
33
|
require_relative "storages/active_record_storage"
|
|
27
|
-
ActiveRecordStorage.new
|
|
34
|
+
ActiveRecordStorage.new(cipher: resolved_cipher)
|
|
28
35
|
else
|
|
29
36
|
MemoryStorage.new
|
|
30
37
|
end
|
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
4
|
require_relative "base"
|
|
5
|
+
require_relative "../../../crypto/field_helpers"
|
|
5
6
|
|
|
6
7
|
module Llmemory
|
|
7
8
|
module LongTerm
|
|
8
9
|
module FileBased
|
|
9
10
|
module Storages
|
|
10
11
|
class ActiveRecordStorage < Base
|
|
11
|
-
|
|
12
|
+
include Llmemory::Crypto::FieldHelpers
|
|
13
|
+
|
|
14
|
+
def initialize(cipher: nil)
|
|
15
|
+
@cipher = cipher || Llmemory.build_cipher
|
|
12
16
|
self.class.load_models!
|
|
13
17
|
end
|
|
14
18
|
|
|
@@ -24,7 +28,7 @@ module Llmemory
|
|
|
24
28
|
LlmemoryResource.create!(
|
|
25
29
|
id: id,
|
|
26
30
|
user_id: user_id,
|
|
27
|
-
text: text,
|
|
31
|
+
text: enc(text),
|
|
28
32
|
created_at: Time.current
|
|
29
33
|
)
|
|
30
34
|
id
|
|
@@ -36,24 +40,26 @@ module Llmemory
|
|
|
36
40
|
id: id,
|
|
37
41
|
user_id: user_id,
|
|
38
42
|
category: category,
|
|
39
|
-
content: content,
|
|
43
|
+
content: enc(content),
|
|
40
44
|
source_resource_id: source_resource_id,
|
|
41
45
|
created_at: Time.current
|
|
42
46
|
}
|
|
43
47
|
attrs[:importance] = importance if LlmemoryItem.column_names.include?("importance")
|
|
44
|
-
|
|
48
|
+
if provenance && LlmemoryItem.column_names.include?("provenance")
|
|
49
|
+
attrs[:provenance] = cipher.enabled? ? enc_json(provenance) : provenance
|
|
50
|
+
end
|
|
45
51
|
LlmemoryItem.create!(attrs)
|
|
46
52
|
id
|
|
47
53
|
end
|
|
48
54
|
|
|
49
55
|
def load_category(user_id, category_name)
|
|
50
56
|
rec = LlmemoryCategory.find_by(user_id: user_id, category_name: category_name)
|
|
51
|
-
rec ? rec.content.to_s : ""
|
|
57
|
+
rec ? dec(rec.content.to_s) : ""
|
|
52
58
|
end
|
|
53
59
|
|
|
54
60
|
def save_category(user_id, category_name, content)
|
|
55
61
|
rec = LlmemoryCategory.find_or_initialize_by(user_id: user_id, category_name: category_name)
|
|
56
|
-
rec.content = content
|
|
62
|
+
rec.content = enc(content)
|
|
57
63
|
rec.updated_at = Time.current
|
|
58
64
|
rec.save!
|
|
59
65
|
true
|
|
@@ -101,7 +107,7 @@ module Llmemory
|
|
|
101
107
|
id: "item_#{SecureRandom.hex(8)}",
|
|
102
108
|
user_id: user_id,
|
|
103
109
|
category: merged_item[:category],
|
|
104
|
-
content: merged_item[:content],
|
|
110
|
+
content: enc(merged_item[:content]),
|
|
105
111
|
source_resource_id: merged_item[:source_resource_id],
|
|
106
112
|
created_at: created_at
|
|
107
113
|
}
|
|
@@ -194,19 +200,19 @@ module Llmemory
|
|
|
194
200
|
h = {
|
|
195
201
|
id: r.id,
|
|
196
202
|
category: r.category,
|
|
197
|
-
content: r.content,
|
|
203
|
+
content: dec(r.content),
|
|
198
204
|
source_resource_id: r.source_resource_id,
|
|
199
205
|
created_at: r.created_at
|
|
200
206
|
}
|
|
201
207
|
h[:importance] = r.respond_to?(:importance) ? (r.importance || 0.7).to_f : 0.7
|
|
202
|
-
h[:provenance] = r.provenance if r.respond_to?(:provenance)
|
|
208
|
+
h[:provenance] = parse_provenance(r.provenance) if r.respond_to?(:provenance)
|
|
203
209
|
h
|
|
204
210
|
end
|
|
205
211
|
|
|
206
212
|
def row_to_resource(r)
|
|
207
213
|
{
|
|
208
214
|
id: r.id,
|
|
209
|
-
text: r.text,
|
|
215
|
+
text: dec(r.text),
|
|
210
216
|
created_at: r.created_at
|
|
211
217
|
}
|
|
212
218
|
end
|