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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8e44ccb1c23fc659d9607e1eb3181e598a57edb95f47b94df4060ad46bfe7c31
4
- data.tar.gz: 5d80e1fefb1dd77cbdc8c0b4d24e588abaf6f18c570db10e3fc0b2c808b09461
3
+ metadata.gz: eb36583aecef9a4a0d4a89e703d1edf7326112df260ceccd1b181cf1411a45d1
4
+ data.tar.gz: b91c1b67b6c88c12d0515a03304aa04b0f61bc310acbb6e1b02391fbe9944d08
5
5
  SHA512:
6
- metadata.gz: edb2f849efc9a6d1dbabc0be0a1210bf21e77f33174b965e3b22e9df3ca90aa536e47152c75891e548d6b43a08d82cfda29afa59d3b2e777aa144f4a3900570f
7
- data.tar.gz: a8c84411d9c262dee5b5a0c07835c05816283a17749c1b6b282c75694dc56a497d8fb9f2b18eeccd242a82d5dce36234342ede75a13e2739ad11f99fb23ae005
6
+ metadata.gz: 597d6cd707b07ebc3c754e2ac67269143c6bff221a22529e692b8a4cc4fc4415180b83f97d7336f95e6eef025c75a18f64819daa740588f032052fccca1a3632
7
+ data.tar.gz: 7b9ec61f4edb2cc053e86f1f478e7a23c82784732fbc918ca6bf35756583e568c92039c7a1cdf2939c6066df76e85ee99176f03b44c82ae708f2705093cb67e8
data/README.md CHANGED
@@ -14,6 +14,8 @@ gem "llmemory"
14
14
 
15
15
  Then run `bundle install`.
16
16
 
17
+ **Upgrading?** See the [Migration guide](docs/MIGRATION.md) for database columns, encryption backfill, and graph store configuration changes.
18
+
17
19
  ## Quick Start (Unified API)
18
20
 
19
21
  The recommended way to use llmemory in a chat is the unified `Llmemory::Memory` API. It abstracts short-term (conversation history) and long-term (extracted facts) and combines retrieval from both:
@@ -51,6 +53,39 @@ memory.clear_session!
51
53
  - **`prune!(mode: nil)`** — Prunes oversized tool results (soft-trim or hard-clear). Only when `prune_tool_results_enabled` is true.
52
54
  - **`check_context_window!`** — Triggers consolidate and compact when context exceeds configured thresholds.
53
55
  - **`clear_session!`** — Clears short-term only.
56
+ - **`llm_usage`** — Returns cumulative LLM token usage for this `user_id` (chat/completions + embeddings), persisted in the short-term store.
57
+
58
+ ## LLM token usage
59
+
60
+ llmemory captures **real token counts** from OpenAI and Anthropic API responses (chat and embeddings), accumulates them per `user_id`, and exposes them for cost monitoring.
61
+
62
+ ```ruby
63
+ memory = Llmemory::Memory.new(user_id: "user_123")
64
+ memory.consolidate!
65
+ memory.maintain!
66
+
67
+ usage = memory.llm_usage
68
+ # => {
69
+ # invoke: { input_tokens: 1200, output_tokens: 400, total_tokens: 1600, calls: 3 },
70
+ # embed: { total_tokens: 48, calls: 2 },
71
+ # updated_at: "2026-07-02T12:00:00Z"
72
+ # }
73
+ ```
74
+
75
+ | What | Details |
76
+ |------|---------|
77
+ | **Counted** | `consolidate!`, reflection, skill mining, compaction summaries, iterative retrieval, graph/file extraction, OpenAI embeddings (index + search) |
78
+ | **Scope** | Cumulative per `user_id` (not per session); stored under pseudo-session `__llm_usage__` |
79
+ | **Not counted** | `context_tokens` (local byte estimate), retrieval context budget, MCP auth tokens |
80
+ | **Cache** | Embedding cache hits record zero tokens |
81
+
82
+ **Other surfaces:**
83
+
84
+ - **CLI:** `llmemory stats USER_ID` prints an `LLM TOKEN USAGE` section.
85
+ - **MCP:** `memory_stats` includes the same totals.
86
+ - **Rails metrics:** subscribe to `llm_invoke.llmemory` and `llm_embed.llmemory` (payload includes `input_tokens`, `output_tokens`, `total_tokens`, `response_chars`).
87
+
88
+ Dollar cost is not computed — multiply tokens by your model pricing externally. For lower-level access, `Llmemory::LLM::OpenAI#invoke` returns a `Response` with `#content` (via `#to_s`) and `#usage`.
54
89
 
55
90
  ## Configuration
56
91
 
@@ -138,7 +173,7 @@ memory = Llmemory::Memory.new(user_id: "agent-1", encryption_key: "tenant-specif
138
173
  **What is encrypted:** conversation checkpoints (redis/postgres/active_record), file-based facts/resources/categories, episodic/procedural documents, graph node names/types/predicates (deterministic) and properties (random IV). **Vector embeddings are not encrypted** (required for pgvector search); associated `text_content` metadata is encrypted.
139
174
 
140
175
  **Trade-offs:**
141
- - Database keyword search (`LIKE`, BM25 on encrypted columns) no longer works on ciphertext; file backends still search in memory after decrypt.
176
+ - Database keyword search on encrypted columns requires the `search_tokens` blind-index column (see [Migration guide](docs/MIGRATION.md)); file backends search in memory after decrypt without schema changes.
142
177
  - `:memory` backends are in-process only and are **not** encrypted at rest.
143
178
  - Existing plaintext data remains readable (markers `enc:v1:` / `encd:v1:`); new writes are encrypted when enabled.
144
179
  - Deterministic encryption on graph identifiers leaks equality (same name ⇒ same ciphertext) but keeps graph traversal working.
@@ -685,7 +720,7 @@ MCP_TOKEN=your-secret-token llmemory mcp serve --http --port 443 \
685
720
  | `memory_timeline_context` | Get N items before/after a specific memory |
686
721
  | `memory_add_message` | Add message to short-term conversation (roles: user, assistant, system, tool, tool_result) |
687
722
  | `memory_consolidate` | Extract facts from conversation to long-term |
688
- | `memory_stats` | Get memory statistics for a user |
723
+ | `memory_stats` | Get memory statistics for a user (includes LLM token usage) |
689
724
  | `memory_info` | Documentation on how to use the tools |
690
725
  | `memory_episode_record` / `memory_episodes` | Record / list episodic trajectories |
691
726
  | `memory_skill_register` / `memory_skill_report` / `memory_skills` | Register / outcome-track / list procedural skills |
@@ -6,6 +6,7 @@ class CreateLlmemoryTables < ActiveRecord::Migration[7.0]
6
6
  t.string :id, null: false, primary_key: true
7
7
  t.string :user_id, null: false
8
8
  t.text :text, null: false
9
+ t.text :search_tokens
9
10
  t.timestamps
10
11
  end
11
12
  add_index :llmemory_resources, :user_id
@@ -18,6 +19,7 @@ class CreateLlmemoryTables < ActiveRecord::Migration[7.0]
18
19
  t.string :source_resource_id
19
20
  t.float :importance, default: 0.7
20
21
  t.jsonb :provenance
22
+ t.text :search_tokens
21
23
  t.timestamps
22
24
  end
23
25
  add_index :llmemory_items, :user_id
@@ -36,6 +38,7 @@ class CreateLlmemoryTables < ActiveRecord::Migration[7.0]
36
38
  t.string :user_id, null: false
37
39
  t.jsonb :data, null: false, default: {}
38
40
  t.text :search_text
41
+ t.text :search_tokens
39
42
  t.datetime :archived_at
40
43
  t.timestamps
41
44
  end
@@ -47,6 +50,8 @@ class CreateLlmemoryTables < ActiveRecord::Migration[7.0]
47
50
  t.string :user_id, null: false
48
51
  t.jsonb :data, null: false, default: {}
49
52
  t.text :search_text
53
+ t.text :search_tokens
54
+ t.text :name_det
50
55
  t.datetime :archived_at
51
56
  t.timestamps
52
57
  end
@@ -41,6 +41,11 @@ module Llmemory
41
41
  puts "Long-term (file) categories: #{storage.list_categories(user_id).size}"
42
42
  puts "Long-term (file) resources: #{storage.list_resources(user_id: user_id).size}"
43
43
  end
44
+
45
+ puts "---"
46
+ puts Llmemory::LLM::UsageLedger.format_text(
47
+ Llmemory::LLM::UsageLedger.new(store: short_store).totals(user_id)
48
+ )
44
49
  end
45
50
 
46
51
  def print_global_stats(short_store, long_type)
@@ -36,6 +36,10 @@ module Llmemory
36
36
  def encrypted?(str)
37
37
  false
38
38
  end
39
+
40
+ def blind_index(token)
41
+ token.to_s.downcase
42
+ end
39
43
  end
40
44
 
41
45
  # AES-256-GCM encryption with separate content (random IV) and index
@@ -111,6 +115,14 @@ module Llmemory
111
115
  s.start_with?(MARKER) || s.start_with?(DETERMINISTIC_MARKER)
112
116
  end
113
117
 
118
+ # Deterministic HMAC digest for blind-index keyword search over encrypted fields.
119
+ def blind_index(token)
120
+ str = token.to_s.downcase
121
+ return str if str.empty?
122
+
123
+ OpenSSL::HMAC.hexdigest("SHA256", @index_key, str)[0, 32]
124
+ end
125
+
114
126
  private
115
127
 
116
128
  def encrypt_with_key(plaintext, key, iv:, marker:)
@@ -105,6 +105,31 @@ module Llmemory
105
105
  rescue JSON::ParserError
106
106
  nil
107
107
  end
108
+
109
+ # Space-delimited blind-index digests for encrypted keyword search.
110
+ def search_tokens_for(text)
111
+ tokens = Llmemory::Tokenizer.tokenize(text).uniq
112
+ return nil if tokens.empty?
113
+
114
+ digests = tokens.map { |t| cipher.blind_index(t) }
115
+ " #{digests.join(' ')} "
116
+ end
117
+
118
+ # OR-of-token SQL filter. Uses blind index on `search_tokens_column` when
119
+ # encryption is enabled; otherwise LIKE on the plaintext `column`.
120
+ def blind_token_filter(column, query, start_index, search_tokens_column: nil)
121
+ tokens = Llmemory::Tokenizer.tokenize(query)
122
+ return ["", []] if tokens.empty?
123
+
124
+ if cipher.enabled? && search_tokens_column
125
+ digests = tokens.map { |t| cipher.blind_index(t) }
126
+ likes = digests.each_index.map { |i| "#{search_tokens_column} LIKE $#{start_index + i}" }
127
+ [" AND (#{likes.join(' OR ')})", digests.map { |d| "% #{d} %" }]
128
+ else
129
+ likes = tokens.each_index.map { |i| "LOWER(#{column}) LIKE $#{start_index + i}" }
130
+ [" AND (#{likes.join(' OR ')})", tokens.map { |t| "%#{t}%" }]
131
+ end
132
+ end
108
133
  end
109
134
  end
110
135
  end
@@ -10,8 +10,10 @@ module Llmemory
10
10
  # Events (payload keys are best-effort; subscribers should treat them as
11
11
  # optional):
12
12
  #
13
- # llm_invoke.llmemory provider:, model:, prompt_chars:, response_chars:
14
- # llm_embed.llmemory provider:, model:, text_chars:, dimensions:
13
+ # llm_invoke.llmemory provider:, model:, prompt_chars:, response_chars:,
14
+ # input_tokens:, output_tokens:, total_tokens:
15
+ # llm_embed.llmemory provider:, model:, text_chars:, input_tokens:,
16
+ # output_tokens:, total_tokens:
15
17
  # memory_write.llmemory memory_type:, user_id:
16
18
  # memory_forget.llmemory memory_type:, user_id:, count:
17
19
  # retrieve.llmemory query_chars:, candidates:, results:
@@ -11,6 +11,7 @@ module Llmemory
11
11
  DEFAULT_MODEL = "claude-sonnet-4-6"
12
12
 
13
13
  def initialize(api_key: nil, model: nil, base_url: nil)
14
+ super()
14
15
  @api_key = api_key || config.llm_api_key || ENV["ANTHROPIC_API_KEY"]
15
16
  @model = model || config.llm_model || DEFAULT_MODEL
16
17
  @base_url = base_url || config.llm_base_url || DEFAULT_BASE_URL
@@ -18,7 +19,8 @@ module Llmemory
18
19
 
19
20
  def invoke(prompt)
20
21
  result = nil
21
- Llmemory::Instrumentation.instrument(:llm_invoke, provider: :anthropic, model: @model, prompt_chars: prompt.to_s.length) do
22
+ payload = { provider: :anthropic, model: @model, prompt_chars: prompt.to_s.length }
23
+ Llmemory::Instrumentation.instrument(:llm_invoke, payload) do
22
24
  response = connection.post("v1/messages") do |req|
23
25
  req.body = {
24
26
  model: @model,
@@ -33,8 +35,11 @@ module Llmemory
33
35
  raise Llmemory::LLMError, "Anthropic API error: #{response.body}" unless response.success?
34
36
 
35
37
  body = response.body.is_a?(Hash) ? response.body : JSON.parse(response.body.to_s)
36
- content = body.dig("content", 0, "text")
37
- result = content&.strip || ""
38
+ content = body.dig("content", 0, "text")&.strip || ""
39
+ usage = parse_anthropic_usage(body["usage"])
40
+ record_usage(usage)
41
+ payload.merge!(instrumentation_payload(usage, content))
42
+ result = Response.new(content, usage: usage)
38
43
  end
39
44
  result
40
45
  end
@@ -1,8 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "usage"
4
+ require_relative "response"
5
+
3
6
  module Llmemory
4
7
  module LLM
5
8
  class Base
9
+ attr_reader :last_usage
10
+
11
+ def initialize(*)
12
+ @last_usage = Usage.zero
13
+ end
14
+
6
15
  def invoke(prompt)
7
16
  raise NotImplementedError, "#{self.class}#invoke must be implemented"
8
17
  end
@@ -18,6 +27,39 @@ module Llmemory
18
27
  def config
19
28
  Llmemory.configuration
20
29
  end
30
+
31
+ def parse_openai_chat_usage(raw)
32
+ return Usage.zero unless raw.is_a?(Hash)
33
+
34
+ Usage.new(
35
+ input_tokens: raw["prompt_tokens"] || raw[:prompt_tokens] || 0,
36
+ output_tokens: raw["completion_tokens"] || raw[:completion_tokens] || 0,
37
+ total_tokens: raw["total_tokens"] || raw[:total_tokens]
38
+ )
39
+ end
40
+
41
+ def parse_anthropic_usage(raw)
42
+ return Usage.zero unless raw.is_a?(Hash)
43
+
44
+ input = raw["input_tokens"] || raw[:input_tokens] || 0
45
+ output = raw["output_tokens"] || raw[:output_tokens] || 0
46
+ Usage.new(input_tokens: input, output_tokens: output)
47
+ end
48
+
49
+ def parse_openai_embed_usage(raw)
50
+ return Usage.zero unless raw.is_a?(Hash)
51
+
52
+ total = raw["total_tokens"] || raw[:total_tokens] || 0
53
+ Usage.new(input_tokens: 0, output_tokens: 0, total_tokens: total)
54
+ end
55
+
56
+ def record_usage(usage)
57
+ @last_usage = usage
58
+ end
59
+
60
+ def instrumentation_payload(usage, content, extra = {})
61
+ usage.to_h.merge(response_chars: content.to_s.length).merge(extra)
62
+ end
21
63
  end
22
64
  end
23
65
  end
@@ -11,6 +11,7 @@ module Llmemory
11
11
  DEFAULT_MODEL = "gpt-4"
12
12
 
13
13
  def initialize(api_key: nil, model: nil, base_url: nil)
14
+ super()
14
15
  @api_key = api_key || config.llm_api_key
15
16
  @model = model || config.llm_model || DEFAULT_MODEL
16
17
  @base_url = base_url || config.llm_base_url || DEFAULT_BASE_URL
@@ -18,7 +19,8 @@ module Llmemory
18
19
 
19
20
  def invoke(prompt)
20
21
  result = nil
21
- Llmemory::Instrumentation.instrument(:llm_invoke, provider: :openai, model: @model, prompt_chars: prompt.to_s.length) do
22
+ payload = { provider: :openai, model: @model, prompt_chars: prompt.to_s.length }
23
+ Llmemory::Instrumentation.instrument(:llm_invoke, payload) do
22
24
  response = connection.post("chat/completions") do |req|
23
25
  req.body = {
24
26
  model: @model,
@@ -32,7 +34,11 @@ module Llmemory
32
34
  raise Llmemory::LLMError, "OpenAI API error: #{response.body}" unless response.success?
33
35
 
34
36
  body = response.body.is_a?(Hash) ? response.body : JSON.parse(response.body.to_s)
35
- result = body.dig("choices", 0, "message", "content")&.strip || ""
37
+ content = body.dig("choices", 0, "message", "content")&.strip || ""
38
+ usage = parse_openai_chat_usage(body["usage"])
39
+ record_usage(usage)
40
+ payload.merge!(instrumentation_payload(usage, content))
41
+ result = Response.new(content, usage: usage)
36
42
  end
37
43
  result
38
44
  end
@@ -54,18 +60,27 @@ module Llmemory
54
60
  }
55
61
  }
56
62
  }
57
- response = connection.post("chat/completions") do |req|
58
- req.body = payload.to_json
59
- req.headers["Content-Type"] = "application/json"
60
- req.headers["Authorization"] = "Bearer #{@api_key}"
61
- end
63
+ parsed = nil
64
+ instrument_payload = { provider: :openai, model: @model, prompt_chars: prompt.to_s.length }
65
+ Llmemory::Instrumentation.instrument(:llm_invoke, instrument_payload) do
66
+ response = connection.post("chat/completions") do |req|
67
+ req.body = payload.to_json
68
+ req.headers["Content-Type"] = "application/json"
69
+ req.headers["Authorization"] = "Bearer #{@api_key}"
70
+ end
62
71
 
63
- raise Llmemory::LLMError, "OpenAI API error: #{response.body}" unless response.success?
72
+ raise Llmemory::LLMError, "OpenAI API error: #{response.body}" unless response.success?
73
+
74
+ body = response.body.is_a?(Hash) ? response.body : JSON.parse(response.body.to_s)
75
+ content = body.dig("choices", 0, "message", "content")&.strip
76
+ usage = parse_openai_chat_usage(body["usage"])
77
+ record_usage(usage)
78
+ instrument_payload.merge!(instrumentation_payload(usage, content.to_s))
79
+ return {} if content.nil? || content.empty?
64
80
 
65
- body = response.body.is_a?(Hash) ? response.body : JSON.parse(response.body.to_s)
66
- content = body.dig("choices", 0, "message", "content")&.strip
67
- return {} if content.nil? || content.empty?
68
- JSON.parse(content)
81
+ parsed = JSON.parse(content)
82
+ end
83
+ parsed
69
84
  rescue JSON::ParserError => e
70
85
  raise Llmemory::LLMError, "Failed to parse JSON response: #{e.message}"
71
86
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module LLM
5
+ class Response
6
+ attr_reader :content, :usage
7
+
8
+ def initialize(content, usage: Usage.zero)
9
+ @content = content.to_s
10
+ @usage = usage
11
+ end
12
+
13
+ def to_s
14
+ @content
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "usage_recorder"
4
+
5
+ module Llmemory
6
+ module LLM
7
+ # Transparent wrapper that records token usage to the per-user ledger.
8
+ class TrackingClient
9
+ def initialize(inner, user_id:, store: nil, api_key: nil)
10
+ @inner = inner
11
+ @user_id = user_id
12
+ @store = store
13
+ @api_key = api_key
14
+ end
15
+
16
+ def invoke(prompt)
17
+ response = inner_client.invoke(prompt)
18
+ usage = if response.respond_to?(:usage)
19
+ response.usage
20
+ elsif inner_client.respond_to?(:last_usage)
21
+ inner_client.last_usage
22
+ else
23
+ Usage.zero
24
+ end
25
+ UsageRecorder.record(user_id: @user_id, usage: usage, operation: :invoke, store: @store)
26
+ response
27
+ end
28
+
29
+ def invoke_with_json_schema(prompt, json_schema)
30
+ result = inner_client.invoke_with_json_schema(prompt, json_schema)
31
+ usage = inner_client.respond_to?(:last_usage) ? inner_client.last_usage : Usage.zero
32
+ UsageRecorder.record(user_id: @user_id, usage: usage, operation: :invoke, store: @store)
33
+ result
34
+ end
35
+
36
+ def last_usage
37
+ return inner_client.last_usage if inner_client.respond_to?(:last_usage)
38
+
39
+ Usage.zero
40
+ end
41
+
42
+ def respond_to?(method, include_private = false)
43
+ inner_client.respond_to?(method, include_private) || super
44
+ end
45
+
46
+ def method_missing(method, *args, &block)
47
+ if inner_client.respond_to?(method)
48
+ inner_client.public_send(method, *args, &block)
49
+ else
50
+ super
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def inner_client
57
+ @inner_client ||= @inner || Llmemory::LLM.client(api_key: @api_key)
58
+ end
59
+ end
60
+ end
61
+ end
@@ -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
 
@@ -135,6 +135,7 @@ module Llmemory
135
135
  vs = vector_store
136
136
  return if vs.nil? || text.to_s.strip.empty?
137
137
  embedding = vs.embed(text)
138
+ record_embed_usage(vs)
138
139
  return unless embedding
139
140
  vs.store(id: id, embedding: embedding, metadata: { text: text, created_at: Time.now }, user_id: @user_id)
140
141
  rescue StandardError
@@ -142,7 +143,9 @@ module Llmemory
142
143
  end
143
144
 
144
145
  def vector_candidates(query, top_k, vs)
145
- vs.search_by_text(query.to_s, top_k: top_k, user_id: @user_id).filter_map do |r|
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|
146
149
  raw = @storage.get_episode(@user_id, r[:id] || r["id"])
147
150
  raw && candidate_for(raw, (r[:score] || r["score"] || 1.0).to_f)
148
151
  end
@@ -183,6 +186,14 @@ module Llmemory
183
186
  return nil if actions.empty?
184
187
  "Episode with #{normalized.size} step(s): #{actions.join(' -> ')}"
185
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
186
197
  end
187
198
  end
188
199
  end