claude_memory 0.9.1 → 0.11.0

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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/memory.sqlite3 +0 -0
  3. data/.claude/skills/dashboard/SKILL.md +42 -0
  4. data/.claude-plugin/marketplace.json +1 -1
  5. data/.claude-plugin/plugin.json +1 -1
  6. data/CHANGELOG.md +130 -0
  7. data/CLAUDE.md +30 -6
  8. data/README.md +66 -2
  9. data/db/migrations/015_add_activity_events.rb +26 -0
  10. data/db/migrations/016_add_moment_feedback.rb +22 -0
  11. data/db/migrations/017_add_last_recalled_at.rb +15 -0
  12. data/docs/1_0_punchlist.md +371 -0
  13. data/docs/EXAMPLES.md +41 -2
  14. data/docs/GETTING_STARTED.md +33 -4
  15. data/docs/architecture.md +22 -7
  16. data/docs/audit-queries.md +131 -0
  17. data/docs/dashboard.md +192 -0
  18. data/docs/improvements.md +650 -9
  19. data/docs/influence/cq.md +187 -0
  20. data/docs/plugin.md +13 -6
  21. data/docs/quality_review.md +524 -172
  22. data/docs/reflection_memory_as_accumulating_judgment.md +67 -0
  23. data/lib/claude_memory/activity_log.rb +86 -0
  24. data/lib/claude_memory/commands/census_command.rb +210 -0
  25. data/lib/claude_memory/commands/completion_command.rb +3 -0
  26. data/lib/claude_memory/commands/dashboard_command.rb +54 -0
  27. data/lib/claude_memory/commands/dedupe_conflicts_command.rb +55 -0
  28. data/lib/claude_memory/commands/digest_command.rb +273 -0
  29. data/lib/claude_memory/commands/hook_command.rb +61 -2
  30. data/lib/claude_memory/commands/initializers/hooks_configurator.rb +7 -4
  31. data/lib/claude_memory/commands/reclassify_references_command.rb +56 -0
  32. data/lib/claude_memory/commands/registry.rb +7 -1
  33. data/lib/claude_memory/commands/show_command.rb +90 -0
  34. data/lib/claude_memory/commands/skills/distill-transcripts.md +13 -1
  35. data/lib/claude_memory/commands/stats_command.rb +131 -2
  36. data/lib/claude_memory/commands/sweep_command.rb +2 -0
  37. data/lib/claude_memory/configuration.rb +16 -0
  38. data/lib/claude_memory/core/relative_time.rb +9 -0
  39. data/lib/claude_memory/dashboard/api.rb +610 -0
  40. data/lib/claude_memory/dashboard/conflicts.rb +279 -0
  41. data/lib/claude_memory/dashboard/efficacy.rb +127 -0
  42. data/lib/claude_memory/dashboard/fact_presenter.rb +109 -0
  43. data/lib/claude_memory/dashboard/health.rb +175 -0
  44. data/lib/claude_memory/dashboard/index.html +2707 -0
  45. data/lib/claude_memory/dashboard/knowledge.rb +136 -0
  46. data/lib/claude_memory/dashboard/moments.rb +244 -0
  47. data/lib/claude_memory/dashboard/reuse.rb +97 -0
  48. data/lib/claude_memory/dashboard/scoped_fact_resolver.rb +95 -0
  49. data/lib/claude_memory/dashboard/server.rb +211 -0
  50. data/lib/claude_memory/dashboard/timeline.rb +68 -0
  51. data/lib/claude_memory/dashboard/trust.rb +454 -0
  52. data/lib/claude_memory/distill/bare_conclusion_detector.rb +71 -0
  53. data/lib/claude_memory/distill/reference_material_detector.rb +78 -0
  54. data/lib/claude_memory/hook/auto_memory_mirror.rb +112 -0
  55. data/lib/claude_memory/hook/context_injector.rb +97 -3
  56. data/lib/claude_memory/hook/handler.rb +191 -3
  57. data/lib/claude_memory/mcp/handlers/management_handlers.rb +8 -0
  58. data/lib/claude_memory/mcp/query_guide.rb +11 -0
  59. data/lib/claude_memory/mcp/text_summary.rb +29 -0
  60. data/lib/claude_memory/mcp/tool_definitions.rb +13 -0
  61. data/lib/claude_memory/mcp/tools.rb +148 -0
  62. data/lib/claude_memory/publish.rb +13 -21
  63. data/lib/claude_memory/recall/stale_detector.rb +67 -0
  64. data/lib/claude_memory/resolve/predicate_policy.rb +2 -0
  65. data/lib/claude_memory/resolve/resolver.rb +41 -11
  66. data/lib/claude_memory/store/llm_cache.rb +68 -0
  67. data/lib/claude_memory/store/metrics_aggregator.rb +96 -0
  68. data/lib/claude_memory/store/schema_manager.rb +1 -1
  69. data/lib/claude_memory/store/sqlite_store.rb +47 -143
  70. data/lib/claude_memory/store/store_manager.rb +29 -0
  71. data/lib/claude_memory/sweep/maintenance.rb +216 -0
  72. data/lib/claude_memory/sweep/recall_timestamp_refresher.rb +83 -0
  73. data/lib/claude_memory/sweep/sweeper.rb +2 -0
  74. data/lib/claude_memory/templates/hooks.example.json +5 -0
  75. data/lib/claude_memory/version.rb +1 -1
  76. data/lib/claude_memory.rb +24 -0
  77. metadata +51 -1
@@ -44,11 +44,34 @@ module ClaudeMemory
44
44
  ToolDefinitions.all
45
45
  end
46
46
 
47
+ # Tools that represent recall/query usage - tracked for efficacy
48
+ RECALL_TOOLS = %w[
49
+ memory.recall memory.recall_index memory.recall_semantic
50
+ memory.search_concepts memory.decisions memory.conventions memory.architecture
51
+ ].freeze
52
+
53
+ # Write tools worth tracking
54
+ WRITE_TOOLS = %w[memory.store_extraction].freeze
55
+
56
+ TRACKED_TOOLS = (RECALL_TOOLS + WRITE_TOOLS).freeze
57
+
47
58
  # Dispatch a tool call to the appropriate handler method.
48
59
  # @param name [String] fully-qualified tool name (e.g. "memory.recall")
49
60
  # @param arguments [Hash] tool arguments from the MCP request
50
61
  # @return [Hash] structured result hash for the tool response
51
62
  def call(name, arguments)
63
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
64
+
65
+ result = dispatch(name, arguments)
66
+
67
+ log_tool_activity(name, arguments, result, t0) if TRACKED_TOOLS.include?(name)
68
+
69
+ result
70
+ end
71
+
72
+ private
73
+
74
+ def dispatch(name, arguments)
52
75
  case name
53
76
  when "memory.recall" then recall(arguments)
54
77
  when "memory.recall_index" then recall_index(arguments)
@@ -74,6 +97,7 @@ module ClaudeMemory
74
97
  when "memory.mark_distilled" then mark_distilled(arguments)
75
98
  when "memory.check_setup" then check_setup
76
99
  when "memory.list_projects" then list_projects
100
+ when "memory.activity" then activity(arguments)
77
101
  else {error: "Unknown tool: #{name}"}
78
102
  end
79
103
  end
@@ -111,6 +135,130 @@ module ClaudeMemory
111
135
  @legacy_store
112
136
  end
113
137
  end
138
+
139
+ def log_tool_activity(name, arguments, result, t0)
140
+ store = default_store
141
+ return unless store
142
+
143
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round
144
+ event_type = WRITE_TOOLS.include?(name) ? "store_extraction" : "recall"
145
+ status = result[:error] ? "error" : "success"
146
+ session_id = extract_session_id(arguments)
147
+
148
+ details = {tool: name}
149
+ if event_type == "recall"
150
+ details[:query] = arguments["query"] || arguments["concepts"]&.join(", ")
151
+ details[:scope] = arguments["scope"]
152
+ details[:result_count] = extract_result_count(result)
153
+ # top_fact_ids is a flat list of the first 5 IDs; top_facts_by_scope
154
+ # groups the same IDs by source so dashboard readers can resolve
155
+ # each ID from the DB it actually came from. Fact IDs autoincrement
156
+ # per-DB, so a bare ID without scope is ambiguous.
157
+ scoped = extract_top_facts_scoped(result)
158
+ details[:top_fact_ids] = scoped.values.flatten.first(5)
159
+ details[:top_facts_by_scope] = scoped if scoped.any?
160
+ details[:results_by_scope] = extract_scope_breakdown(result)
161
+ else
162
+ details[:facts_created] = result[:facts_created]
163
+ details[:entities_created] = result[:entities_created]
164
+ details[:content_item_id] = result[:content_item_id]
165
+ end
166
+
167
+ ActivityLog.record(store, event_type: event_type, status: status,
168
+ session_id: session_id, duration_ms: duration_ms, details: details.compact)
169
+ end
170
+
171
+ # Probe a recall result for a count of returned items. Falls back to
172
+ # counting the first array-valued key among the shapes emitted by the
173
+ # various recall handlers (facts, results, items, concepts).
174
+ def extract_result_count(result)
175
+ return 0 unless result.is_a?(Hash)
176
+ [:fact_count, :count, :results_count].each do |key|
177
+ val = result[key]
178
+ return val if val.is_a?(Integer)
179
+ end
180
+ [:facts, :results, :items, :concepts, :conflicts].each do |key|
181
+ val = result[key]
182
+ return val.size if val.is_a?(Array)
183
+ end
184
+ 0
185
+ end
186
+
187
+ # Capture up to 5 fact ids from a recall result, grouped by source scope.
188
+ # Fact IDs autoincrement per-DB, so without scope a bare ID is ambiguous
189
+ # (project fact #1 and global fact #1 are different facts). Recall rows
190
+ # carry either a :source or :scope field identifying which DB the fact
191
+ # came from; we use that to group.
192
+ #
193
+ # @return [Hash{String => Array<Integer>}] e.g. {"project" => [5, 8], "global" => [1]}
194
+ def extract_top_facts_scoped(result, limit: 5)
195
+ return {} unless result.is_a?(Hash)
196
+ collection = [:facts, :results, :items].map { |k| result[k] }.find { |v| v.is_a?(Array) }
197
+ return {} unless collection
198
+
199
+ grouped = Hash.new { |h, k| h[k] = [] }
200
+ collection.first(limit).each do |row|
201
+ next unless row.is_a?(Hash)
202
+ fact = row[:fact] || row["fact"] || row
203
+ id = fact.is_a?(Hash) ? (fact[:id] || fact["id"]) : nil
204
+ next unless id
205
+ scope = row[:source] || row["source"] || fact[:scope] || fact["scope"] || "project"
206
+ grouped[scope.to_s] << id
207
+ end
208
+ grouped
209
+ end
210
+
211
+ def extract_session_id(arguments)
212
+ (arguments.is_a?(Hash) && arguments["session_id"]) || Configuration.new.session_id
213
+ end
214
+
215
+ # Count returned items grouped by their :scope field so the dashboard
216
+ # can show whether a recall's hits came from global preferences, project
217
+ # facts, or both. Returns nil when the result shape doesn't carry facts.
218
+ # @return [Hash{String => Integer}, nil]
219
+ def extract_scope_breakdown(result)
220
+ return nil unless result.is_a?(Hash)
221
+ collection = [:facts, :results, :items].map { |k| result[k] }.find { |v| v.is_a?(Array) }
222
+ return nil unless collection
223
+
224
+ breakdown = Hash.new(0)
225
+ collection.each { |row|
226
+ next unless row.is_a?(Hash)
227
+ scope = row[:scope] || row["scope"] || row[:source] || row["source"] || "unknown"
228
+ breakdown[scope.to_s] += 1
229
+ }
230
+ breakdown.empty? ? nil : breakdown
231
+ end
232
+
233
+ # Return whichever store is available for activity logging. Delegates
234
+ # to StoreManager#default_store which prefers the project store and
235
+ # falls back to global — preventing silent drops of activity events
236
+ # when the project DB hasn't been initialized yet.
237
+ def default_store
238
+ return @legacy_store unless @manager
239
+ @manager.default_store(prefer: :project)
240
+ end
241
+
242
+ def activity(args)
243
+ store = default_store
244
+ return {error: "No database available"} unless store
245
+
246
+ limit = args["limit"] || 50
247
+ event_type = args["event_type"]
248
+ since = args["since"]
249
+
250
+ events = ActivityLog.recent(store, limit: limit, event_type: event_type, since: since)
251
+ summary = ActivityLog.summary(store, since: since)
252
+
253
+ {
254
+ event_count: events.size,
255
+ summary: summary,
256
+ events: events.map { |e|
257
+ e[:occurred_ago] = Core::RelativeTime.format(e[:occurred_at])
258
+ e
259
+ }
260
+ }
261
+ end
114
262
  end
115
263
  end
116
264
  end
@@ -112,38 +112,30 @@ module ClaudeMemory
112
112
 
113
113
  # @return [String] Markdown section for decision facts
114
114
  def generate_decisions_section(facts)
115
- decisions = facts.select { |f| Resolve::PredicatePolicy.section_for(f[:predicate]) == :decisions }
116
- return "" if decisions.empty?
117
-
118
- lines = ["## Current Decisions\n"]
119
- decisions.each do |d|
120
- lines << "- #{d[:object_literal]}"
115
+ generate_section(facts, section: :decisions, title: "Current Decisions") do |d|
116
+ "- #{d[:object_literal]}"
121
117
  end
122
- lines.join("\n") + "\n"
123
118
  end
124
119
 
125
120
  # @return [String] Markdown section for convention facts
126
121
  def generate_conventions_section(facts)
127
- conventions = facts.select { |f| Resolve::PredicatePolicy.section_for(f[:predicate]) == :conventions }
128
- return "" if conventions.empty?
129
-
130
- lines = ["## Conventions\n"]
131
- conventions.each do |c|
132
- lines << "- #{c[:object_literal]}"
122
+ generate_section(facts, section: :conventions, title: "Conventions") do |c|
123
+ "- #{c[:object_literal]}"
133
124
  end
134
- lines.join("\n") + "\n"
135
125
  end
136
126
 
137
127
  # @return [String] Markdown section for technical constraint facts
138
128
  def generate_constraints_section(facts)
139
- constraints = facts.select { |f| Resolve::PredicatePolicy.section_for(f[:predicate]) == :constraints }
140
- return "" if constraints.empty?
141
-
142
- lines = ["## Technical Constraints\n"]
143
- constraints.each do |c|
144
- lines << "- **#{humanize(c[:predicate])}**: #{c[:object_literal]}"
129
+ generate_section(facts, section: :constraints, title: "Technical Constraints") do |c|
130
+ "- **#{humanize(c[:predicate])}**: #{c[:object_literal]}"
145
131
  end
146
- lines.join("\n") + "\n"
132
+ end
133
+
134
+ def generate_section(facts, section:, title:, &row_formatter)
135
+ rows = facts.select { |f| Resolve::PredicatePolicy.section_for(f[:predicate]) == section }
136
+ return "" if rows.empty?
137
+
138
+ (["## #{title}\n"] + rows.map(&row_formatter)).join("\n") + "\n"
147
139
  end
148
140
 
149
141
  # @return [String] Markdown section for additional knowledge grouped by predicate
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ class Recall
5
+ # #35 access-based staleness — read-only query layer over the
6
+ # last_recalled_at column populated by Sweep::RecallTimestampRefresher.
7
+ #
8
+ # An active fact is "stale" when:
9
+ # - It hasn't been recalled or context-injected within `threshold_days`
10
+ # (last_recalled_at < cutoff OR last_recalled_at is NULL), AND
11
+ # - It was created before the cutoff too — freshly extracted facts
12
+ # aren't dead weight, they just haven't had a chance to be used.
13
+ #
14
+ # No auto-deletion. The point is to surface a count and a list to the
15
+ # user so they can review and reject; the sweeper never acts on this.
16
+ module StaleDetector
17
+ module_function
18
+
19
+ # @param manager [Store::StoreManager]
20
+ # @param threshold_days [Integer] grace window in days
21
+ # @param limit [Integer] max rows per scope (0 = unlimited)
22
+ # @return [Hash] {project: [...], global: [...], total: Int}
23
+ def stale_facts(manager, threshold_days:, limit: 50)
24
+ cutoff = (Time.now.utc - threshold_days * 86_400).iso8601
25
+ result = {project: [], global: [], total: 0}
26
+
27
+ %w[project global].each do |scope|
28
+ store = manager.store_if_exists(scope)
29
+ next unless store
30
+ rows = stale_rows_for(store, cutoff, limit)
31
+ result[scope.to_sym] = rows
32
+ result[:total] += rows.size
33
+ end
34
+
35
+ result
36
+ end
37
+
38
+ # Scope-agnostic count helper for the dashboard sidebar. Avoids
39
+ # materializing rows when only a count is needed.
40
+ #
41
+ # @return [Integer] total stale facts across both stores
42
+ def stale_count(manager, threshold_days:)
43
+ cutoff = (Time.now.utc - threshold_days * 86_400).iso8601
44
+ count = 0
45
+ %w[project global].each do |scope|
46
+ store = manager.store_if_exists(scope)
47
+ next unless store
48
+ count += stale_dataset(store, cutoff).count
49
+ end
50
+ count
51
+ end
52
+
53
+ def stale_dataset(store, cutoff)
54
+ store.facts
55
+ .where(status: "active")
56
+ .where { created_at < cutoff }
57
+ .where { (last_recalled_at < cutoff) | {last_recalled_at: nil} }
58
+ end
59
+
60
+ def stale_rows_for(store, cutoff, limit)
61
+ ds = stale_dataset(store, cutoff).order(Sequel.asc(:last_recalled_at)).order_append(:created_at)
62
+ ds = ds.limit(limit) if limit > 0
63
+ ds.all
64
+ end
65
+ end
66
+ end
67
+ end
@@ -17,6 +17,7 @@ module ClaudeMemory
17
17
  "convention" => {cardinality: :multi, exclusive: false},
18
18
  "decision" => {cardinality: :multi, exclusive: false},
19
19
  "architecture" => {cardinality: :multi, exclusive: false},
20
+ "reference" => {cardinality: :multi, exclusive: false},
20
21
  "uses_framework" => {cardinality: :multi, exclusive: false},
21
22
  "uses_language" => {cardinality: :multi, exclusive: false},
22
23
  "uses_database" => {cardinality: :single, exclusive: true},
@@ -46,6 +47,7 @@ module ClaudeMemory
46
47
  SECTION_MAP = {
47
48
  "decision" => :decisions,
48
49
  "convention" => :conventions,
50
+ "reference" => :references,
49
51
  "uses_database" => :constraints,
50
52
  "uses_framework" => :constraints,
51
53
  "uses_language" => :constraints,
@@ -106,17 +106,26 @@ module ClaudeMemory
106
106
  end
107
107
 
108
108
  def determine_resolution(existing_facts, fact_data, entity_ids)
109
- return :insert unless PredicatePolicy.single?(fact_data[:predicate]) && existing_facts.any?
110
-
109
+ return :insert if existing_facts.empty?
110
+
111
+ # Always reinforce on an exact match — works for both single- and
112
+ # multi-value predicates. Without this check, multi-value predicates
113
+ # like uses_language and uses_framework accumulated an identical
114
+ # fact every ingest cycle (one ruby fact per Stop hook), because
115
+ # the old :insert fast-path for multi-value never looked at the
116
+ # existing set.
111
117
  object_entity_id = entity_ids[fact_data[:object]]
112
118
  matching = existing_facts.find { |f| values_match?(f, fact_data[:object], object_entity_id) }
113
-
114
- if matching
115
- :reinforce
116
- elsif supersession_signal?(fact_data)
117
- :supersede
119
+ return :reinforce if matching
120
+
121
+ # No exact match: for multi-value predicates the new object is
122
+ # genuinely a new coexisting value. For single-value, either the
123
+ # user signaled supersession ("now we use X instead") or the new
124
+ # claim contradicts the current one.
125
+ if PredicatePolicy.single?(fact_data[:predicate])
126
+ supersession_signal?(fact_data) ? :supersede : :conflict
118
127
  else
119
- :conflict
128
+ :insert
120
129
  end
121
130
  end
122
131
 
@@ -141,6 +150,20 @@ module ClaudeMemory
141
150
  end
142
151
 
143
152
  def apply_conflict(existing_facts, fact_data, subject_id, content_item_id, occurred_at, project_path:, scope:)
153
+ # Before creating a new disputed fact + conflict row, check whether
154
+ # we've already recorded this exact contradiction against the same
155
+ # active slot. Without this guard, every re-extraction of the losing
156
+ # value produced a new disputed fact + conflict row — the production
157
+ # DB accumulated 11 identical sqlite-vs-postgresql conflict rows that
158
+ # way. facts_for_slot defaults to status="active", so the existing
159
+ # disputed fact stayed invisible until we explicitly asked for it.
160
+ existing_disputed = @store.facts_for_slot(subject_id, fact_data[:predicate], status: "disputed")
161
+ matching = existing_disputed.find { |f| values_match?(f, fact_data[:object], nil) }
162
+ if matching
163
+ add_provenance(matching[:id], content_item_id, fact_data)
164
+ return {created: 0, superseded: 0, conflicts: 0, provenance: 1}
165
+ end
166
+
144
167
  create_conflict(existing_facts.first[:id], fact_data, subject_id, content_item_id, occurred_at,
145
168
  project_path: project_path, scope: scope)
146
169
  {created: 0, superseded: 0, conflicts: 1, provenance: 0}
@@ -162,8 +185,15 @@ module ClaudeMemory
162
185
  end
163
186
 
164
187
  def insert_new_fact(fact_data, subject_id, entity_ids, occurred_at, project_path:, scope:)
165
- fact_scope = fact_data[:scope_hint] || scope
166
- fact_project = (fact_scope == "global") ? nil : project_path
188
+ # The fact's scope MUST match the store it's being written to.
189
+ # The distiller may emit scope_hint: "global" when text matches
190
+ # patterns like "always" / "my preference", but scope_hint is
191
+ # advisory — it doesn't route the write. Honoring it as a scope
192
+ # override produced "scope=global" rows inside the project DB
193
+ # (orphaned facts that were never visible to global recall). Users
194
+ # who want a project fact in global memory use `claude-memory
195
+ # promote`, which does the proper cross-store copy.
196
+ fact_project = (scope == "global") ? nil : project_path
167
197
 
168
198
  @store.insert_fact(
169
199
  subject_entity_id: subject_id,
@@ -173,7 +203,7 @@ module ClaudeMemory
173
203
  polarity: fact_data[:polarity] || "positive",
174
204
  confidence: fact_data[:confidence] || 1.0,
175
205
  valid_from: occurred_at,
176
- scope: fact_scope,
206
+ scope: scope,
177
207
  project_path: fact_project
178
208
  )
179
209
  end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module ClaudeMemory
6
+ module Store
7
+ # LLM cache persistence for the SQLiteStore.
8
+ # Keyed on SHA-256 of "{operation}:{model}:{input_hash}" so identical
9
+ # (operation, model, input) tuples collapse to a single row via upsert.
10
+ # Pruning is age-based — callers decide the retention window.
11
+ module LLMCache
12
+ # Look up a cached LLM result by its cache key.
13
+ # @param cache_key [String] SHA-256 hex cache key
14
+ # @return [Hash, nil]
15
+ def llm_cache_lookup(cache_key)
16
+ llm_cache.where(cache_key: cache_key).first
17
+ end
18
+
19
+ # Store or update a cached LLM result. Uses upsert on the cache_key.
20
+ # @param operation [String] operation name (e.g. "distill", "embed")
21
+ # @param model [String] model identifier
22
+ # @param input_hash [String] SHA-256 hex digest of the input
23
+ # @param result_json [String] JSON-serialized result
24
+ # @param input_tokens [Integer, nil] input tokens consumed
25
+ # @param output_tokens [Integer, nil] output tokens consumed
26
+ # @return [void]
27
+ def llm_cache_store(operation:, model:, input_hash:, result_json:, input_tokens: nil, output_tokens: nil)
28
+ cache_key = Digest::SHA256.hexdigest("#{operation}:#{model}:#{input_hash}")
29
+
30
+ llm_cache
31
+ .insert_conflict(target: :cache_key, update: {
32
+ result_json: result_json,
33
+ input_tokens: input_tokens,
34
+ output_tokens: output_tokens,
35
+ created_at: Time.now.utc.iso8601
36
+ })
37
+ .insert(
38
+ cache_key: cache_key,
39
+ operation: operation,
40
+ model: model,
41
+ input_hash: input_hash,
42
+ result_json: result_json,
43
+ input_tokens: input_tokens,
44
+ output_tokens: output_tokens,
45
+ created_at: Time.now.utc.iso8601
46
+ )
47
+ end
48
+
49
+ # Compute the cache key for an LLM operation.
50
+ # @param operation [String] operation name
51
+ # @param model [String] model identifier
52
+ # @param input [String] raw input text
53
+ # @return [String] SHA-256 hex cache key
54
+ def llm_cache_key(operation, model, input)
55
+ input_hash = Digest::SHA256.hexdigest(input)
56
+ Digest::SHA256.hexdigest("#{operation}:#{model}:#{input_hash}")
57
+ end
58
+
59
+ # Delete LLM cache entries older than the given age.
60
+ # @param max_age_seconds [Integer] maximum age in seconds (default: 7 days)
61
+ # @return [Integer] number of rows deleted
62
+ def llm_cache_prune(max_age_seconds: 604_800)
63
+ cutoff = (Time.now - max_age_seconds).utc.iso8601
64
+ llm_cache.where { created_at < cutoff }.delete
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Store
5
+ # Ingestion metrics persistence and aggregation for the SQLiteStore.
6
+ # Records per-distillation LLM token usage and extraction counts, and
7
+ # computes totals + efficiency ratios over the full history.
8
+ module MetricsAggregator
9
+ # Count content items that have not yet been distilled.
10
+ # @param min_length [Integer] minimum byte_len threshold
11
+ # @return [Integer]
12
+ def count_undistilled(min_length: 200)
13
+ content_items
14
+ .left_join(:ingestion_metrics, content_item_id: :id)
15
+ .where(Sequel[:ingestion_metrics][:id] => nil)
16
+ .where { byte_len >= min_length }
17
+ .count
18
+ end
19
+
20
+ # Record token usage and extraction counts for a distillation run.
21
+ # @param content_item_id [Integer] content item that was distilled
22
+ # @param input_tokens [Integer] LLM input tokens consumed
23
+ # @param output_tokens [Integer] LLM output tokens consumed
24
+ # @param facts_extracted [Integer] number of facts extracted
25
+ # @return [Integer] inserted row id
26
+ def record_ingestion_metrics(content_item_id:, input_tokens:, output_tokens:, facts_extracted:)
27
+ ingestion_metrics.insert(
28
+ content_item_id: content_item_id,
29
+ input_tokens: input_tokens,
30
+ output_tokens: output_tokens,
31
+ facts_extracted: facts_extracted,
32
+ created_at: Time.now.utc.iso8601
33
+ )
34
+ end
35
+
36
+ # Compute aggregate ingestion metrics across all distillation runs.
37
+ # @return [Hash, nil] totals and efficiency ratio, or nil if no data
38
+ def aggregate_ingestion_metrics
39
+ # standard:disable Performance/Detect (Sequel DSL requires .select{}.first)
40
+ result = ingestion_metrics
41
+ .select {
42
+ [
43
+ sum(:input_tokens).as(:total_input),
44
+ sum(:output_tokens).as(:total_output),
45
+ sum(:facts_extracted).as(:total_facts),
46
+ count(:id).as(:total_ops)
47
+ ]
48
+ }
49
+ .first
50
+ # standard:enable Performance/Detect
51
+
52
+ return nil if result.nil? || result[:total_ops].to_i.zero?
53
+
54
+ total_input = result[:total_input].to_i
55
+ total_output = result[:total_output].to_i
56
+ total_facts = result[:total_facts].to_i
57
+ total_ops = result[:total_ops].to_i
58
+
59
+ efficiency = total_input.zero? ? 0.0 : (total_facts.to_f / total_input * 1000).round(2)
60
+
61
+ {
62
+ total_input_tokens: total_input,
63
+ total_output_tokens: total_output,
64
+ total_facts_extracted: total_facts,
65
+ total_operations: total_ops,
66
+ avg_facts_per_1k_input_tokens: efficiency
67
+ }
68
+ end
69
+
70
+ # Mark all undistilled content items as distilled with zero token counts.
71
+ # Used for backfilling legacy content that predates the metrics table.
72
+ # @return [Integer] number of items backfilled
73
+ def backfill_distillation_metrics!
74
+ undistilled_ids = content_items
75
+ .left_join(:ingestion_metrics, content_item_id: :id)
76
+ .where(Sequel[:ingestion_metrics][:id] => nil)
77
+ .select_map(Sequel[:content_items][:id])
78
+
79
+ return 0 if undistilled_ids.empty?
80
+
81
+ now = Time.now.utc.iso8601
82
+ undistilled_ids.each do |cid|
83
+ ingestion_metrics.insert(
84
+ content_item_id: cid,
85
+ input_tokens: 0,
86
+ output_tokens: 0,
87
+ facts_extracted: 0,
88
+ created_at: now
89
+ )
90
+ end
91
+
92
+ undistilled_ids.size
93
+ end
94
+ end
95
+ end
96
+ end
@@ -5,7 +5,7 @@ module ClaudeMemory
5
5
  # Schema migration and version management for SQLiteStore.
6
6
  # Handles Sequel migrations, legacy version syncing, and initial setup.
7
7
  module SchemaManager
8
- SCHEMA_VERSION = 14
8
+ SCHEMA_VERSION = 17
9
9
 
10
10
  private
11
11