llmemory 0.2.6 → 0.2.8
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 +22 -5
- data/app/controllers/llmemory/dashboard/application_controller.rb +12 -0
- data/app/views/llmemory/dashboard/short_term/show.html.erb +1 -1
- data/lib/llmemory/active_record_helpers.rb +21 -0
- data/lib/llmemory/cli/commands/mcp.rb +5 -3
- data/lib/llmemory/configuration.rb +41 -6
- data/lib/llmemory/crypto/field_helpers.rb +5 -0
- data/lib/llmemory/extractors/entity_relation_extractor.rb +9 -2
- data/lib/llmemory/extractors/fact_extractor.rb +67 -1
- data/lib/llmemory/forget_log.rb +7 -3
- data/lib/llmemory/llm/anthropic.rb +4 -6
- data/lib/llmemory/llm/http_client.rb +55 -0
- data/lib/llmemory/llm/openai.rb +5 -7
- data/lib/llmemory/llm/tracking_client.rb +22 -12
- data/lib/llmemory/llm/usage.rb +4 -0
- data/lib/llmemory/llm/usage_ledger.rb +25 -24
- data/lib/llmemory/long_term/episodic/memory.rb +5 -2
- data/lib/llmemory/long_term/episodic/storages/active_record_storage.rb +3 -1
- data/lib/llmemory/long_term/file_based/memory.rb +9 -4
- data/lib/llmemory/long_term/file_based/storage.rb +15 -2
- data/lib/llmemory/long_term/file_based/storages/active_record_storage.rb +3 -1
- data/lib/llmemory/long_term/graph_based/memory.rb +39 -5
- data/lib/llmemory/long_term/graph_based/storage.rb +15 -2
- data/lib/llmemory/long_term/procedural/memory.rb +5 -2
- data/lib/llmemory/long_term/procedural/storages/active_record_storage.rb +3 -1
- data/lib/llmemory/maintenance/consolidator.rb +6 -2
- data/lib/llmemory/mcp/authentication.rb +5 -3
- data/lib/llmemory/mcp/server.rb +21 -6
- data/lib/llmemory/mcp/store_helpers.rb +35 -0
- data/lib/llmemory/mcp/tools/memory_save.rb +15 -18
- data/lib/llmemory/mcp/tools/memory_search.rb +16 -19
- data/lib/llmemory/memory.rb +29 -11
- data/lib/llmemory/memory_module.rb +6 -2
- data/lib/llmemory/provenance.rb +23 -1
- data/lib/llmemory/retrieval/feedback_store.rb +11 -5
- data/lib/llmemory/retrieval/multi_source.rb +26 -0
- data/lib/llmemory/retrieval/temporal_ranker.rb +4 -2
- data/lib/llmemory/retrieval.rb +1 -0
- data/lib/llmemory/short_term/pruner.rb +16 -4
- data/lib/llmemory/short_term/session_lifecycle.rb +18 -9
- data/lib/llmemory/short_term/stores/active_record_store.rb +37 -8
- data/lib/llmemory/short_term/stores/base.rb +9 -0
- data/lib/llmemory/short_term/stores/key_codec.rb +33 -0
- data/lib/llmemory/short_term/stores/memory_store.rb +38 -7
- data/lib/llmemory/short_term/stores/postgres_store.rb +81 -33
- data/lib/llmemory/short_term/stores/redis_store.rb +85 -9
- data/lib/llmemory/short_term/stores.rb +17 -2
- data/lib/llmemory/vector_store/active_record_store.rb +23 -8
- data/lib/llmemory/vector_store/base.rb +4 -0
- data/lib/llmemory/vector_store/memory_store.rb +9 -0
- data/lib/llmemory/vector_store/openai_embeddings.rb +6 -7
- data/lib/llmemory/version.rb +1 -1
- data/lib/llmemory/working_memory.rb +10 -7
- data/lib/tasks/release.rake +68 -39
- metadata +6 -15
|
@@ -13,58 +13,103 @@ module Llmemory
|
|
|
13
13
|
@database_url = database_url || Llmemory.configuration.database_url
|
|
14
14
|
@connection = nil
|
|
15
15
|
@cipher = cipher || Llmemory.build_cipher
|
|
16
|
+
@table_ready = false
|
|
17
|
+
@conn_mutex = Mutex.new
|
|
16
18
|
end
|
|
17
19
|
|
|
18
20
|
def save(user_id, session_id, state)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
21
|
+
with_conn do |connection|
|
|
22
|
+
ensure_table!(connection)
|
|
23
|
+
connection.exec_params(
|
|
24
|
+
<<~SQL,
|
|
25
|
+
INSERT INTO llmemory_checkpoints (user_id, session_id, state, updated_at)
|
|
26
|
+
VALUES ($1, $2, $3, $4)
|
|
27
|
+
ON CONFLICT (user_id, session_id)
|
|
28
|
+
DO UPDATE SET state = $3, updated_at = $4
|
|
29
|
+
SQL
|
|
30
|
+
[user_id, session_id, serialize(state), Time.now.utc.iso8601]
|
|
31
|
+
)
|
|
32
|
+
end
|
|
29
33
|
true
|
|
30
34
|
end
|
|
31
35
|
|
|
32
36
|
def load(user_id, session_id)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
with_conn do |connection|
|
|
38
|
+
ensure_table!(connection)
|
|
39
|
+
result = connection.exec_params(
|
|
40
|
+
"SELECT state FROM llmemory_checkpoints WHERE user_id = $1 AND session_id = $2",
|
|
41
|
+
[user_id, session_id]
|
|
42
|
+
)
|
|
43
|
+
row = result.first
|
|
44
|
+
row ? deserialize(row["state"]) : nil
|
|
45
|
+
end
|
|
40
46
|
end
|
|
41
47
|
|
|
42
48
|
def delete(user_id, session_id)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
49
|
+
with_conn do |connection|
|
|
50
|
+
ensure_table!(connection)
|
|
51
|
+
connection.exec_params(
|
|
52
|
+
"DELETE FROM llmemory_checkpoints WHERE user_id = $1 AND session_id = $2",
|
|
53
|
+
[user_id, session_id]
|
|
54
|
+
)
|
|
55
|
+
end
|
|
48
56
|
true
|
|
49
57
|
end
|
|
50
58
|
|
|
59
|
+
def update(user_id, session_id, &block)
|
|
60
|
+
with_conn do |connection|
|
|
61
|
+
ensure_table!(connection)
|
|
62
|
+
connection.transaction do
|
|
63
|
+
result = connection.exec_params(
|
|
64
|
+
"SELECT state FROM llmemory_checkpoints WHERE user_id = $1 AND session_id = $2 FOR UPDATE",
|
|
65
|
+
[user_id, session_id]
|
|
66
|
+
)
|
|
67
|
+
row = result.first
|
|
68
|
+
current = row ? deserialize(row["state"]) : nil
|
|
69
|
+
new_state = yield(current)
|
|
70
|
+
next new_state if new_state.nil?
|
|
71
|
+
|
|
72
|
+
connection.exec_params(
|
|
73
|
+
<<~SQL,
|
|
74
|
+
INSERT INTO llmemory_checkpoints (user_id, session_id, state, updated_at)
|
|
75
|
+
VALUES ($1, $2, $3, $4)
|
|
76
|
+
ON CONFLICT (user_id, session_id)
|
|
77
|
+
DO UPDATE SET state = $3, updated_at = $4
|
|
78
|
+
SQL
|
|
79
|
+
[user_id, session_id, serialize(new_state), Time.now.utc.iso8601]
|
|
80
|
+
)
|
|
81
|
+
new_state
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
51
86
|
def list_users
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
87
|
+
with_conn do |connection|
|
|
88
|
+
ensure_table!(connection)
|
|
89
|
+
result = connection.exec("SELECT DISTINCT user_id FROM llmemory_checkpoints")
|
|
90
|
+
result.map { |r| r["user_id"] }
|
|
91
|
+
end
|
|
55
92
|
end
|
|
56
93
|
|
|
57
94
|
def list_sessions(user_id:)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
95
|
+
with_conn do |connection|
|
|
96
|
+
ensure_table!(connection)
|
|
97
|
+
result = connection.exec_params(
|
|
98
|
+
"SELECT session_id FROM llmemory_checkpoints WHERE user_id = $1",
|
|
99
|
+
[user_id]
|
|
100
|
+
)
|
|
101
|
+
result.map { |r| r["session_id"] }
|
|
102
|
+
end
|
|
64
103
|
end
|
|
65
104
|
|
|
66
105
|
private
|
|
67
106
|
|
|
107
|
+
def with_conn
|
|
108
|
+
@conn_mutex.synchronize do
|
|
109
|
+
yield(conn)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
68
113
|
def conn
|
|
69
114
|
@connection ||= begin
|
|
70
115
|
require "pg"
|
|
@@ -72,8 +117,10 @@ module Llmemory
|
|
|
72
117
|
end
|
|
73
118
|
end
|
|
74
119
|
|
|
75
|
-
def ensure_table!
|
|
76
|
-
|
|
120
|
+
def ensure_table!(connection)
|
|
121
|
+
return if @table_ready
|
|
122
|
+
|
|
123
|
+
connection.exec(<<~SQL)
|
|
77
124
|
CREATE TABLE IF NOT EXISTS llmemory_checkpoints (
|
|
78
125
|
user_id TEXT NOT NULL,
|
|
79
126
|
session_id TEXT NOT NULL,
|
|
@@ -82,6 +129,7 @@ module Llmemory
|
|
|
82
129
|
PRIMARY KEY (user_id, session_id)
|
|
83
130
|
)
|
|
84
131
|
SQL
|
|
132
|
+
@table_ready = true
|
|
85
133
|
end
|
|
86
134
|
|
|
87
135
|
def serialize(state)
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "base"
|
|
4
4
|
require_relative "../../crypto/field_helpers"
|
|
5
|
+
require_relative "key_codec"
|
|
6
|
+
require_relative "../session_lifecycle"
|
|
5
7
|
|
|
6
8
|
module Llmemory
|
|
7
9
|
module ShortTerm
|
|
@@ -9,39 +11,96 @@ module Llmemory
|
|
|
9
11
|
class RedisStore < Base
|
|
10
12
|
include Llmemory::Crypto::FieldHelpers
|
|
11
13
|
|
|
14
|
+
KEY_PREFIX = "llmemory:checkpoint"
|
|
15
|
+
MAX_CAS_RETRIES = 5
|
|
16
|
+
|
|
12
17
|
def initialize(redis_url: nil, cipher: nil)
|
|
13
18
|
@redis_url = redis_url || Llmemory.configuration.redis_url
|
|
14
19
|
@redis = nil
|
|
15
20
|
@cipher = cipher || Llmemory.build_cipher
|
|
21
|
+
@conn_mutex = Mutex.new
|
|
16
22
|
end
|
|
17
23
|
|
|
18
24
|
def save(user_id, session_id, state)
|
|
19
|
-
|
|
25
|
+
with_redis do |client|
|
|
26
|
+
key = key_for(user_id, session_id)
|
|
27
|
+
payload = serialize(state)
|
|
28
|
+
if pseudo_session?(session_id)
|
|
29
|
+
client.set(key, payload)
|
|
30
|
+
else
|
|
31
|
+
client.set(key, payload, ex: session_ttl_seconds)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
20
34
|
true
|
|
21
35
|
end
|
|
22
36
|
|
|
23
37
|
def load(user_id, session_id)
|
|
24
|
-
|
|
25
|
-
|
|
38
|
+
with_redis do |client|
|
|
39
|
+
data = client.get(key_for(user_id, session_id))
|
|
40
|
+
data ? deserialize(data) : nil
|
|
41
|
+
end
|
|
26
42
|
end
|
|
27
43
|
|
|
28
44
|
def delete(user_id, session_id)
|
|
29
|
-
|
|
45
|
+
with_redis { |client| client.del(key_for(user_id, session_id)) }
|
|
30
46
|
true
|
|
31
47
|
end
|
|
32
48
|
|
|
49
|
+
def update(user_id, session_id, &block)
|
|
50
|
+
key = key_for(user_id, session_id)
|
|
51
|
+
retries = 0
|
|
52
|
+
|
|
53
|
+
loop do
|
|
54
|
+
result = with_redis do |client|
|
|
55
|
+
client.watch(key) do
|
|
56
|
+
raw = client.get(key)
|
|
57
|
+
current = raw ? deserialize(raw) : nil
|
|
58
|
+
new_state = yield(current)
|
|
59
|
+
next new_state if new_state.nil?
|
|
60
|
+
|
|
61
|
+
payload = serialize(new_state)
|
|
62
|
+
if pseudo_session?(session_id)
|
|
63
|
+
client.multi { |multi| multi.set(key, payload) }
|
|
64
|
+
else
|
|
65
|
+
client.multi { |multi| multi.set(key, payload, ex: session_ttl_seconds) }
|
|
66
|
+
end
|
|
67
|
+
new_state
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
return result unless result.nil?
|
|
71
|
+
|
|
72
|
+
retries += 1
|
|
73
|
+
raise Llmemory::StoreError, "Redis CAS update failed after #{MAX_CAS_RETRIES} retries" if retries >= MAX_CAS_RETRIES
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
33
77
|
def list_users
|
|
34
|
-
|
|
35
|
-
|
|
78
|
+
scan_keys("#{KEY_PREFIX}:*").filter_map do |k|
|
|
79
|
+
parts = k.split(":", 4)
|
|
80
|
+
next unless parts.size >= 4
|
|
81
|
+
|
|
82
|
+
KeyCodec.decode(parts[2])
|
|
83
|
+
end.uniq
|
|
36
84
|
end
|
|
37
85
|
|
|
38
86
|
def list_sessions(user_id:)
|
|
39
|
-
|
|
40
|
-
|
|
87
|
+
encoded_user = KeyCodec.encode(user_id)
|
|
88
|
+
scan_keys("#{KEY_PREFIX}:#{encoded_user}:*").filter_map do |k|
|
|
89
|
+
parts = k.split(":", 4)
|
|
90
|
+
next unless parts.size >= 4
|
|
91
|
+
|
|
92
|
+
KeyCodec.decode(parts[3])
|
|
93
|
+
end
|
|
41
94
|
end
|
|
42
95
|
|
|
43
96
|
private
|
|
44
97
|
|
|
98
|
+
def with_redis
|
|
99
|
+
@conn_mutex.synchronize do
|
|
100
|
+
yield(redis)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
45
104
|
def redis
|
|
46
105
|
@redis ||= begin
|
|
47
106
|
require "redis"
|
|
@@ -49,8 +108,16 @@ module Llmemory
|
|
|
49
108
|
end
|
|
50
109
|
end
|
|
51
110
|
|
|
111
|
+
def scan_keys(match)
|
|
112
|
+
keys = []
|
|
113
|
+
with_redis do |client|
|
|
114
|
+
client.scan_each(match: match) { |key| keys << key }
|
|
115
|
+
end
|
|
116
|
+
keys
|
|
117
|
+
end
|
|
118
|
+
|
|
52
119
|
def key_for(user_id, session_id)
|
|
53
|
-
"
|
|
120
|
+
"#{KEY_PREFIX}:#{KeyCodec.encode(user_id)}:#{KeyCodec.encode(session_id)}"
|
|
54
121
|
end
|
|
55
122
|
|
|
56
123
|
def serialize(state)
|
|
@@ -60,6 +127,15 @@ module Llmemory
|
|
|
60
127
|
def deserialize(data)
|
|
61
128
|
deserialize_state(data)
|
|
62
129
|
end
|
|
130
|
+
|
|
131
|
+
def pseudo_session?(session_id)
|
|
132
|
+
SessionLifecycle.pseudo_session?(session_id)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def session_ttl_seconds
|
|
136
|
+
ttl = Llmemory.configuration.redis_session_ttl_seconds
|
|
137
|
+
ttl.positive? ? ttl : 86_400 * 30
|
|
138
|
+
end
|
|
63
139
|
end
|
|
64
140
|
end
|
|
65
141
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "stores/key_codec"
|
|
3
4
|
require_relative "stores/base"
|
|
4
5
|
require_relative "stores/memory_store"
|
|
5
6
|
require_relative "stores/redis_store"
|
|
@@ -12,8 +13,14 @@ module Llmemory
|
|
|
12
13
|
# Shared by Checkpoint, SessionLifecycle and WorkingMemory.
|
|
13
14
|
def self.build(store_type = nil, cipher: nil)
|
|
14
15
|
resolved_cipher = cipher || Llmemory.build_cipher
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
type = (store_type || Llmemory.configuration.short_term_store).to_sym
|
|
17
|
+
case type
|
|
18
|
+
when :memory
|
|
19
|
+
if Llmemory.configuration.shared_memory_stores
|
|
20
|
+
shared_memory_store(resolved_cipher)
|
|
21
|
+
else
|
|
22
|
+
MemoryStore.new(cipher: resolved_cipher)
|
|
23
|
+
end
|
|
17
24
|
when :redis then RedisStore.new(cipher: resolved_cipher)
|
|
18
25
|
when :postgres then PostgresStore.new(cipher: resolved_cipher)
|
|
19
26
|
when :active_record, :activerecord
|
|
@@ -23,6 +30,14 @@ module Llmemory
|
|
|
23
30
|
MemoryStore.new(cipher: resolved_cipher)
|
|
24
31
|
end
|
|
25
32
|
end
|
|
33
|
+
|
|
34
|
+
def self.shared_memory_store(cipher)
|
|
35
|
+
@shared_memory_store ||= MemoryStore.new(cipher: cipher)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.reset_shared_singletons!
|
|
39
|
+
@shared_memory_store = nil
|
|
40
|
+
end
|
|
26
41
|
end
|
|
27
42
|
end
|
|
28
43
|
end
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "base"
|
|
4
|
+
require_relative "../active_record_helpers"
|
|
4
5
|
|
|
5
6
|
module Llmemory
|
|
6
7
|
module VectorStore
|
|
7
8
|
# Persists embeddings in llmemory_embeddings (pgvector).
|
|
8
9
|
# Use when long_term_store is :active_record so hybrid search finds persisted embeddings.
|
|
9
10
|
class ActiveRecordStore < Base
|
|
11
|
+
include Llmemory::ActiveRecordHelpers
|
|
10
12
|
def initialize(embedding_provider: nil, source_type: "edge", cipher: nil)
|
|
11
13
|
self.class.load_model!
|
|
12
14
|
@embedding_provider = embedding_provider
|
|
@@ -35,14 +37,16 @@ module Llmemory
|
|
|
35
37
|
def store(id:, embedding:, metadata: {}, user_id: nil)
|
|
36
38
|
return id if user_id.nil? || user_id.to_s.empty?
|
|
37
39
|
text_content = (metadata || {}).dig("text") || (metadata || {}).dig(:text)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
with_unique_retry do
|
|
41
|
+
rec = Llmemory::VectorStore::ActiveRecordEmbedding.find_or_initialize_by(
|
|
42
|
+
user_id: user_id.to_s,
|
|
43
|
+
source_type: @source_type,
|
|
44
|
+
source_id: id.to_s
|
|
45
|
+
)
|
|
46
|
+
rec.embedding = embedding.to_a.map(&:to_f)
|
|
47
|
+
rec.text_content = encrypt_text_content(text_content)
|
|
48
|
+
rec.save!
|
|
49
|
+
end
|
|
46
50
|
id
|
|
47
51
|
end
|
|
48
52
|
|
|
@@ -81,6 +85,17 @@ module Llmemory
|
|
|
81
85
|
search(query_embedding, top_k: top_k, user_id: user_id)
|
|
82
86
|
end
|
|
83
87
|
|
|
88
|
+
def delete(id:, user_id: nil)
|
|
89
|
+
return false if user_id.nil? || user_id.to_s.empty?
|
|
90
|
+
|
|
91
|
+
Llmemory::VectorStore::ActiveRecordEmbedding.where(
|
|
92
|
+
user_id: user_id.to_s,
|
|
93
|
+
source_type: @source_type,
|
|
94
|
+
source_id: id.to_s
|
|
95
|
+
).delete_all
|
|
96
|
+
true
|
|
97
|
+
end
|
|
98
|
+
|
|
84
99
|
private
|
|
85
100
|
|
|
86
101
|
def encrypt_text_content(text)
|
|
@@ -14,6 +14,10 @@ module Llmemory
|
|
|
14
14
|
def search(query_embedding, top_k: 10)
|
|
15
15
|
raise NotImplementedError, "#{self.class}#search must be implemented"
|
|
16
16
|
end
|
|
17
|
+
|
|
18
|
+
def delete(id:, user_id: nil)
|
|
19
|
+
raise NotImplementedError, "#{self.class}#delete must be implemented"
|
|
20
|
+
end
|
|
17
21
|
end
|
|
18
22
|
end
|
|
19
23
|
end
|
|
@@ -55,6 +55,15 @@ module Llmemory
|
|
|
55
55
|
search(query_embedding, top_k: top_k, user_id: user_id)
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
+
def delete(id:, user_id: nil)
|
|
59
|
+
if user_id
|
|
60
|
+
@entries.delete("#{user_id}:#{id}")
|
|
61
|
+
else
|
|
62
|
+
@entries.delete_if { |_key, data| data[:source_id].to_s == id.to_s }
|
|
63
|
+
end
|
|
64
|
+
true
|
|
65
|
+
end
|
|
66
|
+
|
|
58
67
|
private
|
|
59
68
|
|
|
60
69
|
def decrypt_metadata(meta)
|
|
@@ -5,18 +5,21 @@ require "json"
|
|
|
5
5
|
require "digest"
|
|
6
6
|
require_relative "base"
|
|
7
7
|
require_relative "../llm/usage"
|
|
8
|
+
require_relative "../llm/http_client"
|
|
8
9
|
|
|
9
10
|
module Llmemory
|
|
10
11
|
module VectorStore
|
|
11
12
|
class OpenAIEmbeddings < Base
|
|
13
|
+
include Llmemory::LLM::HttpClient
|
|
12
14
|
DEFAULT_MODEL = "text-embedding-3-small"
|
|
13
15
|
DEFAULT_DIMS = 1536
|
|
14
16
|
|
|
15
17
|
attr_reader :last_usage
|
|
16
18
|
|
|
17
|
-
def initialize(api_key: nil, model: nil)
|
|
19
|
+
def initialize(api_key: nil, model: nil, base_url: nil)
|
|
18
20
|
@api_key = api_key || Llmemory.configuration.llm_api_key
|
|
19
21
|
@model = model || DEFAULT_MODEL
|
|
22
|
+
@base_url = base_url || Llmemory.configuration.llm_base_url || "https://api.openai.com/v1"
|
|
20
23
|
@cache = {}
|
|
21
24
|
@cache_order = []
|
|
22
25
|
@last_usage = Llmemory::LLM::Usage.zero
|
|
@@ -64,7 +67,7 @@ module Llmemory
|
|
|
64
67
|
result = nil
|
|
65
68
|
payload = { provider: :openai, model: @model, text_chars: text.to_s.length }
|
|
66
69
|
Llmemory::Instrumentation.instrument(:llm_embed, payload) do
|
|
67
|
-
response = connection
|
|
70
|
+
response = post_with_resilience(connection, "embeddings") do |req|
|
|
68
71
|
req.headers["Authorization"] = "Bearer #{@api_key}"
|
|
69
72
|
req.headers["Content-Type"] = "application/json"
|
|
70
73
|
req.body = { input: text.to_s.strip, model: @model }.to_json
|
|
@@ -90,11 +93,7 @@ module Llmemory
|
|
|
90
93
|
end
|
|
91
94
|
|
|
92
95
|
def connection
|
|
93
|
-
@connection ||=
|
|
94
|
-
f.request :json
|
|
95
|
-
f.response :json
|
|
96
|
-
f.adapter Faraday.default_adapter
|
|
97
|
-
end
|
|
96
|
+
@connection ||= build_faraday_connection(@base_url)
|
|
98
97
|
end
|
|
99
98
|
|
|
100
99
|
def store(id:, embedding:, metadata: {})
|
data/lib/llmemory/version.rb
CHANGED
|
@@ -36,18 +36,21 @@ module Llmemory
|
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
def set(slot, value)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
@store.update(@user_id, @store_key) do |state|
|
|
40
|
+
state = symbolize(state || {})
|
|
41
|
+
state[slot.to_sym] = value
|
|
42
|
+
state
|
|
43
|
+
end
|
|
42
44
|
value
|
|
43
45
|
end
|
|
44
46
|
|
|
45
47
|
# Bulk update in a single write.
|
|
46
48
|
def update(**slots)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
@store.update(@user_id, @store_key) do |state|
|
|
50
|
+
state = symbolize(state || {})
|
|
51
|
+
slots.each { |k, v| state[k.to_sym] = v }
|
|
52
|
+
state
|
|
53
|
+
end
|
|
51
54
|
end
|
|
52
55
|
|
|
53
56
|
# Slots set by the caller beyond the predefined typed ones.
|
data/lib/tasks/release.rake
CHANGED
|
@@ -1,22 +1,77 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
namespace :release do
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
RELEASE_FILES = %w[lib/llmemory/version.rb Gemfile.lock CHANGELOG.txt].freeze
|
|
5
|
+
DEFAULT_NOTES_PATH = File.expand_path("../../tmp/release_notes.txt", __dir__)
|
|
6
|
+
|
|
7
|
+
def self.resolve_notes_path(arg)
|
|
8
|
+
path = arg.to_s.strip
|
|
9
|
+
path = DEFAULT_NOTES_PATH if path.empty?
|
|
10
|
+
File.expand_path(path)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.build_changelog_entry(version, date, notes_body)
|
|
14
|
+
body = notes_body.strip
|
|
15
|
+
body = body.sub(/\A#+\s*[^\n]*\n+/, "") # drop optional leading markdown title
|
|
16
|
+
|
|
17
|
+
<<~ENTRY
|
|
18
|
+
|
|
19
|
+
## [#{version}] - #{date}
|
|
20
|
+
|
|
21
|
+
#{body}
|
|
22
|
+
ENTRY
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
desc "Show commits and diff stat since the last tag (helper before writing release notes)"
|
|
26
|
+
task :since_tag do
|
|
27
|
+
last_tag = `git describe --tags --abbrev=0 2>/dev/null`.strip
|
|
28
|
+
range = last_tag.empty? ? "HEAD" : "#{last_tag}..HEAD"
|
|
29
|
+
puts "Last tag: #{last_tag.empty? ? '(none)' : last_tag}"
|
|
30
|
+
puts "Range: #{range}\n\n"
|
|
31
|
+
puts "Commits:"
|
|
32
|
+
puts(`git log #{range} --oneline`.strip)
|
|
33
|
+
puts "\nDiff stat:"
|
|
34
|
+
puts(`git diff #{range} --stat`.strip)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
desc "Bump version (patch|minor|major). Requires tmp/release_notes.txt or notes path. Checks: branch=main, clean tree, tests pass. Then: version, CHANGELOG, commit, push, tag"
|
|
38
|
+
task :bump, [:bump_type, :notes_file] => [] do |_t, args|
|
|
6
39
|
require_relative "../llmemory/version"
|
|
7
40
|
|
|
8
|
-
# Pre-flight checks
|
|
9
41
|
current_branch = `git rev-parse --abbrev-ref HEAD`.strip
|
|
10
42
|
abort "Current branch must be main (got: #{current_branch})" unless current_branch == "main"
|
|
11
43
|
|
|
12
|
-
|
|
13
|
-
release_files = %w[lib/llmemory/version.rb Gemfile.lock CHANGELOG.txt]
|
|
14
|
-
status_lines = `git status --porcelain`.strip.lines
|
|
44
|
+
status_lines = `git status --porcelain --untracked-files=normal`.strip.lines
|
|
15
45
|
other_changes = status_lines.reject do |line|
|
|
16
|
-
path = line.sub(/\A..\s+/, "").strip
|
|
17
|
-
|
|
46
|
+
path = line.sub(/\A..\s+/, "").strip.split(" -> ").last
|
|
47
|
+
RELEASE_FILES.include?(path)
|
|
48
|
+
end
|
|
49
|
+
unless other_changes.empty?
|
|
50
|
+
abort "Working tree has uncommitted changes outside release files. Commit or stash them first.\n#{other_changes.join}"
|
|
18
51
|
end
|
|
19
|
-
|
|
52
|
+
|
|
53
|
+
notes_path = Release.resolve_notes_path(args[:notes_file])
|
|
54
|
+
unless File.exist?(notes_path)
|
|
55
|
+
abort <<~MSG
|
|
56
|
+
Release notes file not found: #{notes_path}
|
|
57
|
+
|
|
58
|
+
1. Inspect changes since the last tag:
|
|
59
|
+
bundle exec rake release:since_tag
|
|
60
|
+
|
|
61
|
+
2. Write a changelog entry (user-visible changes, not raw commits) to:
|
|
62
|
+
tmp/release_notes.txt
|
|
63
|
+
|
|
64
|
+
Use sections such as Added / Changed / Fixed / Removed / Notes.
|
|
65
|
+
Follow the style of recent entries in CHANGELOG.txt (e.g. v0.2.7).
|
|
66
|
+
|
|
67
|
+
3. Re-run:
|
|
68
|
+
bundle exec rake release:bump[#{args[:bump_type] || 'patch'}]
|
|
69
|
+
bundle exec rake release:bump[#{args[:bump_type] || 'patch'},path/to/notes.txt]
|
|
70
|
+
MSG
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
notes_body = File.read(notes_path).strip
|
|
74
|
+
abort "Release notes file is empty: #{notes_path}" if notes_body.empty?
|
|
20
75
|
|
|
21
76
|
puts "Running tests..."
|
|
22
77
|
sh "bundle exec rspec"
|
|
@@ -36,41 +91,21 @@ namespace :release do
|
|
|
36
91
|
new_version_s = new_version.to_s
|
|
37
92
|
|
|
38
93
|
puts "Bumping #{Llmemory::VERSION} -> #{new_version_s} (#{bump_type})"
|
|
94
|
+
puts " Release notes: #{notes_path}"
|
|
39
95
|
|
|
40
|
-
# 1. Update version.rb
|
|
41
96
|
version_file = File.expand_path("../llmemory/version.rb", __dir__)
|
|
42
97
|
content = File.read(version_file)
|
|
43
98
|
content = content.sub(/VERSION = "[^"]+"/, %(VERSION = "#{new_version_s}"))
|
|
44
99
|
File.write(version_file, content)
|
|
45
100
|
puts " Updated lib/llmemory/version.rb"
|
|
46
101
|
|
|
47
|
-
# 2. bundle install
|
|
48
102
|
sh "bundle install"
|
|
49
103
|
puts " Updated Gemfile.lock"
|
|
50
104
|
|
|
51
|
-
# 3. Update CHANGELOG.txt
|
|
52
105
|
changelog_path = File.expand_path("../../CHANGELOG.txt", __dir__)
|
|
53
|
-
changelog_content =
|
|
54
|
-
File.read(changelog_path)
|
|
55
|
-
else
|
|
56
|
-
""
|
|
57
|
-
end
|
|
58
|
-
|
|
106
|
+
changelog_content = File.exist?(changelog_path) ? File.read(changelog_path) : ""
|
|
59
107
|
today = Time.now.strftime("%Y-%m-%d")
|
|
60
|
-
|
|
61
|
-
commits = if last_tag.empty?
|
|
62
|
-
`git log --oneline`.strip
|
|
63
|
-
else
|
|
64
|
-
`git log #{last_tag}..HEAD --oneline`.strip
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
new_entry = <<~CHANGELOG
|
|
68
|
-
|
|
69
|
-
## [#{new_version_s}] - #{today}
|
|
70
|
-
|
|
71
|
-
### Changes
|
|
72
|
-
#{commits.lines.map { |l| "- #{l.strip}" }.join("\n")}
|
|
73
|
-
CHANGELOG
|
|
108
|
+
new_entry = Release.build_changelog_entry(new_version_s, today, notes_body)
|
|
74
109
|
|
|
75
110
|
header = "# Changelog\n\n"
|
|
76
111
|
if changelog_content.empty?
|
|
@@ -82,19 +117,13 @@ namespace :release do
|
|
|
82
117
|
File.write(changelog_path, changelog_content)
|
|
83
118
|
puts " Updated CHANGELOG.txt"
|
|
84
119
|
|
|
85
|
-
# 4. Commit
|
|
86
120
|
sh "git add lib/llmemory/version.rb Gemfile.lock CHANGELOG.txt"
|
|
87
121
|
sh "git commit -m 'Release v#{new_version_s}'"
|
|
88
|
-
|
|
89
|
-
# 5. Push
|
|
90
122
|
sh "git push"
|
|
91
|
-
|
|
92
|
-
# 6. Tag
|
|
93
123
|
sh "git tag v#{new_version_s}"
|
|
94
|
-
|
|
95
|
-
# 7. Push tag
|
|
96
124
|
sh "git push origin v#{new_version_s}"
|
|
97
125
|
|
|
126
|
+
File.delete(notes_path) if File.expand_path(notes_path) == File.expand_path(DEFAULT_NOTES_PATH) && File.exist?(notes_path)
|
|
98
127
|
puts "\nDone. Released v#{new_version_s}"
|
|
99
128
|
end
|
|
100
129
|
end
|