llmemory 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +37 -2
- data/lib/generators/llmemory/install/templates/create_llmemory_tables.rb +5 -0
- data/lib/llmemory/cli/commands/stats.rb +5 -0
- data/lib/llmemory/crypto/cipher.rb +12 -0
- data/lib/llmemory/crypto/field_helpers.rb +25 -0
- data/lib/llmemory/instrumentation.rb +4 -2
- data/lib/llmemory/llm/anthropic.rb +8 -3
- data/lib/llmemory/llm/base.rb +42 -0
- data/lib/llmemory/llm/openai.rb +27 -12
- data/lib/llmemory/llm/response.rb +18 -0
- data/lib/llmemory/llm/tracking_client.rb +61 -0
- data/lib/llmemory/llm/usage.rb +31 -0
- data/lib/llmemory/llm/usage_ledger.rb +118 -0
- data/lib/llmemory/llm/usage_recorder.rb +37 -0
- data/lib/llmemory/llm.rb +5 -0
- data/lib/llmemory/long_term/episodic/memory.rb +12 -1
- data/lib/llmemory/long_term/episodic/storages/active_record_storage.rb +25 -7
- data/lib/llmemory/long_term/episodic/storages/database_storage.rb +28 -11
- data/lib/llmemory/long_term/file_based/storages/active_record_storage.rb +28 -34
- data/lib/llmemory/long_term/file_based/storages/base.rb +24 -0
- data/lib/llmemory/long_term/file_based/storages/database_storage.rb +50 -34
- data/lib/llmemory/long_term/file_based/storages/file_storage.rb +10 -2
- data/lib/llmemory/long_term/file_based/storages/memory_storage.rb +2 -28
- data/lib/llmemory/long_term/graph_based/memory.rb +13 -0
- data/lib/llmemory/long_term/graph_based/storage.rb +3 -1
- data/lib/llmemory/long_term/procedural/memory.rb +12 -1
- data/lib/llmemory/long_term/procedural/storages/active_record_storage.rb +40 -9
- data/lib/llmemory/long_term/procedural/storages/database_storage.rb +57 -17
- data/lib/llmemory/maintenance/search_tokens_backfill.rb +339 -0
- data/lib/llmemory/maintenance.rb +1 -0
- data/lib/llmemory/mcp/tools/memory_retrieve.rb +2 -0
- data/lib/llmemory/mcp/tools/memory_stats.rb +13 -0
- data/lib/llmemory/mcp/tools/memory_timeline.rb +48 -14
- data/lib/llmemory/mcp/tools/memory_timeline_context.rb +15 -5
- data/lib/llmemory/memory.rb +34 -15
- data/lib/llmemory/short_term/checkpoint.rb +2 -0
- data/lib/llmemory/vector_store/active_record_store.rb +6 -0
- data/lib/llmemory/vector_store/memory_store.rb +13 -3
- data/lib/llmemory/vector_store/openai_embeddings.rb +23 -2
- data/lib/llmemory/version.rb +1 -1
- data/lib/tasks/llmemory.rake +37 -0
- metadata +9 -2
|
@@ -31,7 +31,11 @@ module Llmemory
|
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
storage = build_storage
|
|
34
|
-
result =
|
|
34
|
+
result = if graph_based?
|
|
35
|
+
storage.get_edges_around(user_id, reference, before: before_count, after: after_count)
|
|
36
|
+
else
|
|
37
|
+
storage.get_items_around(user_id, reference, before: before_count, after: after_count)
|
|
38
|
+
end
|
|
35
39
|
|
|
36
40
|
::MCP::Tool::Response.new([{
|
|
37
41
|
type: "text",
|
|
@@ -47,13 +51,17 @@ module Llmemory
|
|
|
47
51
|
private
|
|
48
52
|
|
|
49
53
|
def build_storage
|
|
50
|
-
if
|
|
54
|
+
if graph_based?
|
|
51
55
|
LongTerm::GraphBased::Storages.build
|
|
52
56
|
else
|
|
53
57
|
LongTerm::FileBased::Storages.build
|
|
54
58
|
end
|
|
55
59
|
end
|
|
56
60
|
|
|
61
|
+
def graph_based?
|
|
62
|
+
Llmemory.configuration.long_term_type.to_s == "graph_based"
|
|
63
|
+
end
|
|
64
|
+
|
|
57
65
|
def format_context(result, reference)
|
|
58
66
|
output = []
|
|
59
67
|
output << "Timeline Context around '#{reference}':\n"
|
|
@@ -122,10 +130,12 @@ module Llmemory
|
|
|
122
130
|
end
|
|
123
131
|
|
|
124
132
|
def extract_category(item)
|
|
125
|
-
if item.
|
|
126
|
-
item.category
|
|
127
|
-
else
|
|
133
|
+
if item.is_a?(Hash)
|
|
128
134
|
item[:category] || item["category"]
|
|
135
|
+
elsif item.respond_to?(:members) && item.members.include?(:category)
|
|
136
|
+
item.category
|
|
137
|
+
elsif item.respond_to?(:category)
|
|
138
|
+
item.category
|
|
129
139
|
end
|
|
130
140
|
end
|
|
131
141
|
|
data/lib/llmemory/memory.rb
CHANGED
|
@@ -15,22 +15,28 @@ module Llmemory
|
|
|
15
15
|
@session_id = session_id
|
|
16
16
|
resolved_key = encryption_key == :inherit ? nil : encryption_key
|
|
17
17
|
@cipher = Llmemory.build_cipher(resolved_key)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
23
30
|
@working_memory = working_memory
|
|
24
31
|
@episodic = episodic
|
|
25
32
|
@procedural = procedural
|
|
26
|
-
@
|
|
33
|
+
@api_key = api_key unless api_key.to_s.empty?
|
|
27
34
|
type = long_term_type || Llmemory.configuration.long_term_type || :file_based
|
|
28
35
|
@long_term = long_term || build_long_term(type)
|
|
29
|
-
short_term_store = build_short_term_store(@cipher)
|
|
30
36
|
@retrieval_engine = retrieval_engine || Retrieval::Engine.new(
|
|
31
37
|
@long_term,
|
|
32
|
-
llm:
|
|
33
|
-
feedback: Retrieval::FeedbackStore.new(store: short_term_store)
|
|
38
|
+
llm: tracked_llm_client,
|
|
39
|
+
feedback: Retrieval::FeedbackStore.new(store: @short_term_store)
|
|
34
40
|
)
|
|
35
41
|
end
|
|
36
42
|
|
|
@@ -66,14 +72,14 @@ module Llmemory
|
|
|
66
72
|
# Reflects over recent episodes and writes distilled insights to the
|
|
67
73
|
# semantic store (file/graph) with provenance back to source episodes.
|
|
68
74
|
def reflect!(window: 10, category: "insights")
|
|
69
|
-
Reflection::Reflector.new(episodic: episodic, semantic: @long_term, llm:
|
|
75
|
+
Reflection::Reflector.new(episodic: episodic, semantic: @long_term, llm: tracked_llm_client)
|
|
70
76
|
.reflect(window: window, category: category)
|
|
71
77
|
end
|
|
72
78
|
|
|
73
79
|
# Reasoning action: render a prompt from working memory, call the LLM, write
|
|
74
80
|
# the result back. Composable; does not touch long-term memory.
|
|
75
81
|
def reason(template:, into: Actions::Reason::DEFAULT_SLOT, parse: nil)
|
|
76
|
-
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)
|
|
77
83
|
end
|
|
78
84
|
|
|
79
85
|
# Mines recent episodes for reusable skills (Voyager-style). Human-in-the-loop
|
|
@@ -81,7 +87,7 @@ module Llmemory
|
|
|
81
87
|
# `auto_register: true`, registers them in procedural memory (with provenance
|
|
82
88
|
# back to the source episodes) and returns the new skill ids.
|
|
83
89
|
def mine_skills!(window: SkillMining::Miner::DEFAULT_WINDOW, outcomes: nil, auto_register: false)
|
|
84
|
-
SkillMining::Miner.new(episodic: episodic, procedural: procedural, llm:
|
|
90
|
+
SkillMining::Miner.new(episodic: episodic, procedural: procedural, llm: tracked_llm_client)
|
|
85
91
|
.mine(window: window, outcomes: outcomes, auto_register: auto_register)
|
|
86
92
|
end
|
|
87
93
|
|
|
@@ -91,7 +97,7 @@ module Llmemory
|
|
|
91
97
|
def maintain!(**opts)
|
|
92
98
|
Maintenance::CognitivePass.run!(
|
|
93
99
|
@user_id,
|
|
94
|
-
memory: self, episodic: episodic, procedural: procedural, semantic: @long_term, llm:
|
|
100
|
+
memory: self, episodic: episodic, procedural: procedural, semantic: @long_term, llm: tracked_llm_client,
|
|
95
101
|
**opts
|
|
96
102
|
)
|
|
97
103
|
end
|
|
@@ -245,6 +251,10 @@ module Llmemory
|
|
|
245
251
|
@user_id
|
|
246
252
|
end
|
|
247
253
|
|
|
254
|
+
def llm_usage
|
|
255
|
+
Llmemory::LLM::UsageLedger.new(store: @short_term_store).totals(@user_id)
|
|
256
|
+
end
|
|
257
|
+
|
|
248
258
|
private
|
|
249
259
|
|
|
250
260
|
def summarize_messages(msgs)
|
|
@@ -263,7 +273,16 @@ module Llmemory
|
|
|
263
273
|
end
|
|
264
274
|
|
|
265
275
|
def llm_client
|
|
266
|
-
|
|
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
|
+
)
|
|
267
286
|
end
|
|
268
287
|
|
|
269
288
|
def flush_memory_before_compaction!(msgs)
|
|
@@ -339,7 +358,7 @@ module Llmemory
|
|
|
339
358
|
end
|
|
340
359
|
|
|
341
360
|
def build_long_term(long_term_type)
|
|
342
|
-
llm_opts =
|
|
361
|
+
llm_opts = { llm: tracked_llm_client }
|
|
343
362
|
case long_term_type.to_s.to_sym
|
|
344
363
|
when :graph_based
|
|
345
364
|
LongTerm::GraphBased::Memory.new(
|
|
@@ -26,6 +26,12 @@ module Llmemory
|
|
|
26
26
|
@embedding_provider.embed(text)
|
|
27
27
|
end
|
|
28
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
|
+
|
|
29
35
|
def store(id:, embedding:, metadata: {}, user_id: nil)
|
|
30
36
|
return id if user_id.nil? || user_id.to_s.empty?
|
|
31
37
|
text_content = (metadata || {}).dig("text") || (metadata || {}).dig(:text)
|
|
@@ -16,6 +16,12 @@ module Llmemory
|
|
|
16
16
|
@embedding_provider.embed(text)
|
|
17
17
|
end
|
|
18
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
|
+
|
|
19
25
|
def store(id:, embedding:, metadata: {}, user_id: nil)
|
|
20
26
|
key = user_id ? "#{user_id}:#{id}" : id.to_s
|
|
21
27
|
meta = (metadata || {}).dup
|
|
@@ -24,7 +30,11 @@ module Llmemory
|
|
|
24
30
|
elsif meta[:text] && @cipher.enabled?
|
|
25
31
|
meta[:text] = @cipher.encrypt(meta[:text].to_s)
|
|
26
32
|
end
|
|
27
|
-
@entries[key] = {
|
|
33
|
+
@entries[key] = {
|
|
34
|
+
source_id: id.to_s,
|
|
35
|
+
embedding: embedding.to_a.map(&:to_f),
|
|
36
|
+
metadata: meta.merge("user_id" => user_id)
|
|
37
|
+
}
|
|
28
38
|
id
|
|
29
39
|
end
|
|
30
40
|
|
|
@@ -32,9 +42,9 @@ module Llmemory
|
|
|
32
42
|
query = query_embedding.to_a.map(&:to_f)
|
|
33
43
|
return [] if query.empty?
|
|
34
44
|
entries = user_id ? @entries.select { |k, _| k.to_s.start_with?("#{user_id}:") } : @entries
|
|
35
|
-
scores = entries.map do |
|
|
45
|
+
scores = entries.map do |_key, data|
|
|
36
46
|
sim = cosine_similarity(query, data[:embedding])
|
|
37
|
-
{ id:
|
|
47
|
+
{ id: data[:source_id], score: sim, metadata: decrypt_metadata(data[:metadata]) }
|
|
38
48
|
end
|
|
39
49
|
scores.sort_by { |s| -s[:score] }.first(top_k)
|
|
40
50
|
end
|
|
@@ -4,6 +4,7 @@ require "faraday"
|
|
|
4
4
|
require "json"
|
|
5
5
|
require "digest"
|
|
6
6
|
require_relative "base"
|
|
7
|
+
require_relative "../llm/usage"
|
|
7
8
|
|
|
8
9
|
module Llmemory
|
|
9
10
|
module VectorStore
|
|
@@ -11,11 +12,14 @@ module Llmemory
|
|
|
11
12
|
DEFAULT_MODEL = "text-embedding-3-small"
|
|
12
13
|
DEFAULT_DIMS = 1536
|
|
13
14
|
|
|
15
|
+
attr_reader :last_usage
|
|
16
|
+
|
|
14
17
|
def initialize(api_key: nil, model: nil)
|
|
15
18
|
@api_key = api_key || Llmemory.configuration.llm_api_key
|
|
16
19
|
@model = model || DEFAULT_MODEL
|
|
17
20
|
@cache = {}
|
|
18
21
|
@cache_order = []
|
|
22
|
+
@last_usage = Llmemory::LLM::Usage.zero
|
|
19
23
|
end
|
|
20
24
|
|
|
21
25
|
def embed(text)
|
|
@@ -23,7 +27,10 @@ module Llmemory
|
|
|
23
27
|
|
|
24
28
|
if Llmemory.configuration.embedding_cache_enabled
|
|
25
29
|
key = cache_key(text)
|
|
26
|
-
|
|
30
|
+
if @cache.key?(key)
|
|
31
|
+
@last_usage = Llmemory::LLM::Usage.zero
|
|
32
|
+
return @cache[key].dup
|
|
33
|
+
end
|
|
27
34
|
end
|
|
28
35
|
|
|
29
36
|
result = fetch_embedding(text)
|
|
@@ -55,7 +62,8 @@ module Llmemory
|
|
|
55
62
|
|
|
56
63
|
def fetch_embedding(text)
|
|
57
64
|
result = nil
|
|
58
|
-
|
|
65
|
+
payload = { provider: :openai, model: @model, text_chars: text.to_s.length }
|
|
66
|
+
Llmemory::Instrumentation.instrument(:llm_embed, payload) do
|
|
59
67
|
response = connection.post("embeddings") do |req|
|
|
60
68
|
req.headers["Authorization"] = "Bearer #{@api_key}"
|
|
61
69
|
req.headers["Content-Type"] = "application/json"
|
|
@@ -63,11 +71,24 @@ module Llmemory
|
|
|
63
71
|
end
|
|
64
72
|
raise Llmemory::LLMError, "OpenAI Embeddings API error: #{response.body}" unless response.success?
|
|
65
73
|
body = response.body.is_a?(Hash) ? response.body : JSON.parse(response.body.to_s)
|
|
74
|
+
@last_usage = parse_embed_usage(body["usage"])
|
|
75
|
+
payload.merge!(
|
|
76
|
+
input_tokens: @last_usage.input_tokens,
|
|
77
|
+
output_tokens: @last_usage.output_tokens,
|
|
78
|
+
total_tokens: @last_usage.total_tokens
|
|
79
|
+
)
|
|
66
80
|
result = body.dig("data", 0, "embedding")&.map(&:to_f) || Array.new(DEFAULT_DIMS, 0.0)
|
|
67
81
|
end
|
|
68
82
|
result
|
|
69
83
|
end
|
|
70
84
|
|
|
85
|
+
def parse_embed_usage(raw)
|
|
86
|
+
return Llmemory::LLM::Usage.zero unless raw.is_a?(Hash)
|
|
87
|
+
|
|
88
|
+
total = raw["total_tokens"] || raw[:total_tokens] || 0
|
|
89
|
+
Llmemory::LLM::Usage.new(input_tokens: 0, output_tokens: 0, total_tokens: total)
|
|
90
|
+
end
|
|
91
|
+
|
|
71
92
|
def connection
|
|
72
93
|
@connection ||= Faraday.new(url: "https://api.openai.com/v1") do |f|
|
|
73
94
|
f.request :json
|
data/lib/llmemory/version.rb
CHANGED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :llmemory do
|
|
4
|
+
desc "Backfill search_tokens blind index (and name_det on skills) for encrypted keyword search. " \
|
|
5
|
+
"Env: FORCE=1 (re-backfill all rows), DRY_RUN=1 (count only), STORE=active_record|postgres"
|
|
6
|
+
task :backfill_search_tokens, [:user_id] do |_t, args|
|
|
7
|
+
require "llmemory"
|
|
8
|
+
require_relative "../llmemory/maintenance/search_tokens_backfill"
|
|
9
|
+
|
|
10
|
+
dry_run = ENV["DRY_RUN"].to_s == "1"
|
|
11
|
+
force = ENV["FORCE"].to_s == "1"
|
|
12
|
+
store = ENV["STORE"]&.to_sym
|
|
13
|
+
|
|
14
|
+
if store.nil? && !Llmemory.configuration.encryption_enabled
|
|
15
|
+
warn "Note: encryption_enabled is false; backfill still aligns schema but keyword search uses plaintext LIKE."
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
backfill = Llmemory::Maintenance::SearchTokensBackfill.new(
|
|
19
|
+
dry_run: dry_run,
|
|
20
|
+
force: force,
|
|
21
|
+
store: store
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
user_id = args[:user_id].presence
|
|
25
|
+
puts "Backfilling search_tokens#{user_id ? " for user_id=#{user_id}" : " (all users)"} " \
|
|
26
|
+
"[store=#{store || Llmemory.configuration.long_term_store}]" \
|
|
27
|
+
"#{dry_run ? " [DRY RUN]" : ""}#{force ? " [FORCE]" : ""}"
|
|
28
|
+
|
|
29
|
+
result = backfill.run(user_id: user_id)
|
|
30
|
+
summary = result.to_h
|
|
31
|
+
|
|
32
|
+
puts "Updated: items=#{summary[:items]} resources=#{summary[:resources]} " \
|
|
33
|
+
"episodes=#{summary[:episodes]} skills=#{summary[:skills]} " \
|
|
34
|
+
"(total=#{summary[:total]})"
|
|
35
|
+
puts "Dry run — no rows written." if dry_run
|
|
36
|
+
end
|
|
37
|
+
end
|
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.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- llmemory
|
|
@@ -175,6 +175,11 @@ files:
|
|
|
175
175
|
- lib/llmemory/llm/anthropic.rb
|
|
176
176
|
- lib/llmemory/llm/base.rb
|
|
177
177
|
- lib/llmemory/llm/openai.rb
|
|
178
|
+
- lib/llmemory/llm/response.rb
|
|
179
|
+
- lib/llmemory/llm/tracking_client.rb
|
|
180
|
+
- lib/llmemory/llm/usage.rb
|
|
181
|
+
- lib/llmemory/llm/usage_ledger.rb
|
|
182
|
+
- lib/llmemory/llm/usage_recorder.rb
|
|
178
183
|
- lib/llmemory/long_term.rb
|
|
179
184
|
- lib/llmemory/long_term/episodic.rb
|
|
180
185
|
- lib/llmemory/long_term/episodic/episode.rb
|
|
@@ -225,6 +230,7 @@ files:
|
|
|
225
230
|
- lib/llmemory/maintenance/consolidator.rb
|
|
226
231
|
- lib/llmemory/maintenance/reindexer.rb
|
|
227
232
|
- lib/llmemory/maintenance/runner.rb
|
|
233
|
+
- lib/llmemory/maintenance/search_tokens_backfill.rb
|
|
228
234
|
- lib/llmemory/maintenance/summarizer.rb
|
|
229
235
|
- lib/llmemory/maintenance/ttl_expiry.rb
|
|
230
236
|
- lib/llmemory/mcp.rb
|
|
@@ -283,6 +289,7 @@ files:
|
|
|
283
289
|
- lib/llmemory/vector_store/openai_embeddings.rb
|
|
284
290
|
- lib/llmemory/version.rb
|
|
285
291
|
- lib/llmemory/working_memory.rb
|
|
292
|
+
- lib/tasks/llmemory.rake
|
|
286
293
|
- lib/tasks/release.rake
|
|
287
294
|
homepage: https://github.com/entaina/llmemory
|
|
288
295
|
licenses:
|
|
@@ -304,7 +311,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
304
311
|
- !ruby/object:Gem::Version
|
|
305
312
|
version: '0'
|
|
306
313
|
requirements: []
|
|
307
|
-
rubygems_version: 4.0.
|
|
314
|
+
rubygems_version: 4.0.16
|
|
308
315
|
specification_version: 4
|
|
309
316
|
summary: Persistent memory system for LLM agents
|
|
310
317
|
test_files: []
|