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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +37 -2
  3. data/lib/generators/llmemory/install/templates/create_llmemory_tables.rb +5 -0
  4. data/lib/llmemory/cli/commands/stats.rb +5 -0
  5. data/lib/llmemory/crypto/cipher.rb +12 -0
  6. data/lib/llmemory/crypto/field_helpers.rb +25 -0
  7. data/lib/llmemory/instrumentation.rb +4 -2
  8. data/lib/llmemory/llm/anthropic.rb +8 -3
  9. data/lib/llmemory/llm/base.rb +42 -0
  10. data/lib/llmemory/llm/openai.rb +27 -12
  11. data/lib/llmemory/llm/response.rb +18 -0
  12. data/lib/llmemory/llm/tracking_client.rb +61 -0
  13. data/lib/llmemory/llm/usage.rb +31 -0
  14. data/lib/llmemory/llm/usage_ledger.rb +118 -0
  15. data/lib/llmemory/llm/usage_recorder.rb +37 -0
  16. data/lib/llmemory/llm.rb +5 -0
  17. data/lib/llmemory/long_term/episodic/memory.rb +12 -1
  18. data/lib/llmemory/long_term/episodic/storages/active_record_storage.rb +25 -7
  19. data/lib/llmemory/long_term/episodic/storages/database_storage.rb +28 -11
  20. data/lib/llmemory/long_term/file_based/storages/active_record_storage.rb +28 -34
  21. data/lib/llmemory/long_term/file_based/storages/base.rb +24 -0
  22. data/lib/llmemory/long_term/file_based/storages/database_storage.rb +50 -34
  23. data/lib/llmemory/long_term/file_based/storages/file_storage.rb +10 -2
  24. data/lib/llmemory/long_term/file_based/storages/memory_storage.rb +2 -28
  25. data/lib/llmemory/long_term/graph_based/memory.rb +13 -0
  26. data/lib/llmemory/long_term/graph_based/storage.rb +3 -1
  27. data/lib/llmemory/long_term/procedural/memory.rb +12 -1
  28. data/lib/llmemory/long_term/procedural/storages/active_record_storage.rb +40 -9
  29. data/lib/llmemory/long_term/procedural/storages/database_storage.rb +57 -17
  30. data/lib/llmemory/maintenance/search_tokens_backfill.rb +339 -0
  31. data/lib/llmemory/maintenance.rb +1 -0
  32. data/lib/llmemory/mcp/tools/memory_retrieve.rb +2 -0
  33. data/lib/llmemory/mcp/tools/memory_stats.rb +13 -0
  34. data/lib/llmemory/mcp/tools/memory_timeline.rb +48 -14
  35. data/lib/llmemory/mcp/tools/memory_timeline_context.rb +15 -5
  36. data/lib/llmemory/memory.rb +34 -15
  37. data/lib/llmemory/short_term/checkpoint.rb +2 -0
  38. data/lib/llmemory/vector_store/active_record_store.rb +6 -0
  39. data/lib/llmemory/vector_store/memory_store.rb +13 -3
  40. data/lib/llmemory/vector_store/openai_embeddings.rb +23 -2
  41. data/lib/llmemory/version.rb +1 -1
  42. data/lib/tasks/llmemory.rake +37 -0
  43. metadata +9 -2
@@ -31,7 +31,11 @@ module Llmemory
31
31
  end
32
32
 
33
33
  storage = build_storage
34
- result = storage.get_items_around(user_id, reference, before: before_count, after: after_count)
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 Llmemory.configuration.long_term_type.to_s == "graph_based"
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.respond_to?(:category)
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
 
@@ -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
- @checkpoint = checkpoint || ShortTerm::Checkpoint.new(
19
- user_id: user_id,
20
- session_id: session_id,
21
- cipher: @cipher
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
- @llm = api_key.to_s.empty? ? nil : Llmemory::LLM.client(api_key: api_key)
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: @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: @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: @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: @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: @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
- @llm ||= Llmemory::LLM.client
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 = @llm ? { llm: @llm } : {}
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(
@@ -14,6 +14,8 @@ module Llmemory
14
14
  @store = store || build_store
15
15
  end
16
16
 
17
+ attr_reader :store
18
+
17
19
  def save_state(state)
18
20
  @store.save(@user_id, @session_id, state)
19
21
  end
@@ -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] = { embedding: embedding.to_a.map(&:to_f), metadata: meta.merge("user_id" => user_id) }
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 |id, data|
45
+ scores = entries.map do |_key, data|
36
46
  sim = cosine_similarity(query, data[:embedding])
37
- { id: id, score: sim, metadata: decrypt_metadata(data[:metadata]) }
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
- return @cache[key].dup if @cache.key?(key)
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
- Llmemory::Instrumentation.instrument(:llm_embed, provider: :openai, model: @model, text_chars: text.to_s.length) do
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Llmemory
4
- VERSION = "0.2.3"
4
+ VERSION = "0.2.5"
5
5
  end
@@ -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.3
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.10
314
+ rubygems_version: 4.0.16
308
315
  specification_version: 4
309
316
  summary: Persistent memory system for LLM agents
310
317
  test_files: []