claude_memory 0.9.0 → 0.10.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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/memory.sqlite3 +0 -0
  3. data/.claude/rules/claude_memory.generated.md +63 -1
  4. data/.claude/skills/dashboard/SKILL.md +42 -0
  5. data/.claude/skills/release/SKILL.md +168 -0
  6. data/.claude-plugin/marketplace.json +1 -1
  7. data/.claude-plugin/plugin.json +1 -1
  8. data/CHANGELOG.md +92 -0
  9. data/CLAUDE.md +21 -5
  10. data/README.md +32 -2
  11. data/db/migrations/015_add_activity_events.rb +26 -0
  12. data/db/migrations/016_add_moment_feedback.rb +22 -0
  13. data/db/migrations/017_add_last_recalled_at.rb +15 -0
  14. data/docs/1_0_punchlist.md +190 -0
  15. data/docs/EXAMPLES.md +41 -2
  16. data/docs/GETTING_STARTED.md +31 -4
  17. data/docs/architecture.md +22 -7
  18. data/docs/audit-queries.md +131 -0
  19. data/docs/dashboard.md +172 -0
  20. data/docs/improvements.md +465 -9
  21. data/docs/influence/cq.md +187 -0
  22. data/docs/plugin.md +13 -6
  23. data/docs/quality_review.md +489 -172
  24. data/docs/reflection_memory_as_accumulating_judgment.md +67 -0
  25. data/lib/claude_memory/activity_log.rb +86 -0
  26. data/lib/claude_memory/commands/census_command.rb +210 -0
  27. data/lib/claude_memory/commands/completion_command.rb +3 -0
  28. data/lib/claude_memory/commands/dashboard_command.rb +54 -0
  29. data/lib/claude_memory/commands/dedupe_conflicts_command.rb +55 -0
  30. data/lib/claude_memory/commands/digest_command.rb +181 -0
  31. data/lib/claude_memory/commands/hook_command.rb +34 -0
  32. data/lib/claude_memory/commands/reclassify_references_command.rb +56 -0
  33. data/lib/claude_memory/commands/registry.rb +6 -1
  34. data/lib/claude_memory/commands/skills/distill-transcripts.md +13 -1
  35. data/lib/claude_memory/commands/stats_command.rb +38 -1
  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 +285 -0
  52. data/lib/claude_memory/distill/reference_material_detector.rb +78 -0
  53. data/lib/claude_memory/hook/auto_memory_mirror.rb +112 -0
  54. data/lib/claude_memory/hook/context_injector.rb +97 -3
  55. data/lib/claude_memory/hook/handler.rb +50 -3
  56. data/lib/claude_memory/mcp/handlers/management_handlers.rb +8 -0
  57. data/lib/claude_memory/mcp/query_guide.rb +11 -0
  58. data/lib/claude_memory/mcp/server.rb +8 -2
  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/version.rb +1 -1
  75. data/lib/claude_memory.rb +22 -0
  76. metadata +50 -1
@@ -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
 
@@ -8,6 +8,8 @@ require "extralite"
8
8
  require "sequel/adapters/extralite"
9
9
  require_relative "retry_handler"
10
10
  require_relative "schema_manager"
11
+ require_relative "llm_cache"
12
+ require_relative "metrics_aggregator"
11
13
 
12
14
  module ClaudeMemory
13
15
  module Store
@@ -19,6 +21,8 @@ module ClaudeMemory
19
21
  class SQLiteStore
20
22
  include RetryHandler
21
23
  include SchemaManager
24
+ include LLMCache
25
+ include MetricsAggregator
22
26
 
23
27
  # @return [Sequel::Database] the underlying Sequel database connection
24
28
  attr_reader :db
@@ -101,6 +105,49 @@ module ClaudeMemory
101
105
  # @return [Sequel::Dataset]
102
106
  def mcp_tool_calls = @db[:mcp_tool_calls]
103
107
 
108
+ # @return [Sequel::Dataset]
109
+ def activity_events = @db[:activity_events]
110
+
111
+ # @return [Sequel::Dataset]
112
+ def moment_feedback = @db[:moment_feedback]
113
+
114
+ # Upsert a thumbs-up/down verdict for a moment. One row per event_id
115
+ # (unique constraint on the column) — repeat clicks overwrite. Returns
116
+ # the persisted row.
117
+ #
118
+ # @param event_id [Integer] activity_events row id
119
+ # @param verdict [String] "up" or "down"
120
+ # @param note [String, nil] optional freeform note
121
+ # @param recorded_at [String, nil] ISO 8601 timestamp (defaults to now UTC)
122
+ # @return [Hash] row after upsert
123
+ def upsert_moment_feedback(event_id:, verdict:, note: nil, recorded_at: nil)
124
+ raise ArgumentError, "verdict must be 'up' or 'down'" unless %w[up down].include?(verdict)
125
+
126
+ ts = recorded_at || Time.now.utc.iso8601
127
+ with_retry do
128
+ @db.transaction do
129
+ existing = moment_feedback.where(event_id: event_id).first
130
+ if existing
131
+ moment_feedback.where(id: existing[:id]).update(
132
+ verdict: verdict, note: note, recorded_at: ts
133
+ )
134
+ moment_feedback.where(id: existing[:id]).first
135
+ else
136
+ id = moment_feedback.insert(
137
+ event_id: event_id, verdict: verdict, note: note, recorded_at: ts
138
+ )
139
+ moment_feedback.where(id: id).first
140
+ end
141
+ end
142
+ end
143
+ end
144
+
145
+ # Remove the verdict for a moment, if any.
146
+ # @return [Integer] number of rows deleted (0 or 1)
147
+ def clear_moment_feedback(event_id)
148
+ with_retry { moment_feedback.where(event_id: event_id).delete }
149
+ end
150
+
104
151
  # Record a single MCP tool invocation for telemetry.
105
152
  # Inserts synchronously; callers wrap in with_retry at the call site
106
153
  # if needed.
@@ -497,149 +544,6 @@ module ClaudeMemory
497
544
  .all
498
545
  end
499
546
 
500
- # Count content items that have not yet been distilled.
501
- # @param min_length [Integer] minimum byte_len threshold
502
- # @return [Integer]
503
- def count_undistilled(min_length: 200)
504
- content_items
505
- .left_join(:ingestion_metrics, content_item_id: :id)
506
- .where(Sequel[:ingestion_metrics][:id] => nil)
507
- .where { byte_len >= min_length }
508
- .count
509
- end
510
-
511
- # Record token usage and extraction counts for a distillation run.
512
- # @param content_item_id [Integer] content item that was distilled
513
- # @param input_tokens [Integer] LLM input tokens consumed
514
- # @param output_tokens [Integer] LLM output tokens consumed
515
- # @param facts_extracted [Integer] number of facts extracted
516
- # @return [Integer] inserted row id
517
- def record_ingestion_metrics(content_item_id:, input_tokens:, output_tokens:, facts_extracted:)
518
- ingestion_metrics.insert(
519
- content_item_id: content_item_id,
520
- input_tokens: input_tokens,
521
- output_tokens: output_tokens,
522
- facts_extracted: facts_extracted,
523
- created_at: Time.now.utc.iso8601
524
- )
525
- end
526
-
527
- # Compute aggregate ingestion metrics across all distillation runs.
528
- # @return [Hash, nil] totals and efficiency ratio, or nil if no data
529
- def aggregate_ingestion_metrics
530
- # standard:disable Performance/Detect (Sequel DSL requires .select{}.first)
531
- result = ingestion_metrics
532
- .select {
533
- [
534
- sum(:input_tokens).as(:total_input),
535
- sum(:output_tokens).as(:total_output),
536
- sum(:facts_extracted).as(:total_facts),
537
- count(:id).as(:total_ops)
538
- ]
539
- }
540
- .first
541
- # standard:enable Performance/Detect
542
-
543
- return nil if result.nil? || result[:total_ops].to_i.zero?
544
-
545
- total_input = result[:total_input].to_i
546
- total_output = result[:total_output].to_i
547
- total_facts = result[:total_facts].to_i
548
- total_ops = result[:total_ops].to_i
549
-
550
- efficiency = total_input.zero? ? 0.0 : (total_facts.to_f / total_input * 1000).round(2)
551
-
552
- {
553
- total_input_tokens: total_input,
554
- total_output_tokens: total_output,
555
- total_facts_extracted: total_facts,
556
- total_operations: total_ops,
557
- avg_facts_per_1k_input_tokens: efficiency
558
- }
559
- end
560
-
561
- # Mark all undistilled content items as distilled with zero token counts.
562
- # Used for backfilling legacy content that predates the metrics table.
563
- # @return [Integer] number of items backfilled
564
- def backfill_distillation_metrics!
565
- undistilled_ids = content_items
566
- .left_join(:ingestion_metrics, content_item_id: :id)
567
- .where(Sequel[:ingestion_metrics][:id] => nil)
568
- .select_map(Sequel[:content_items][:id])
569
-
570
- return 0 if undistilled_ids.empty?
571
-
572
- now = Time.now.utc.iso8601
573
- undistilled_ids.each do |cid|
574
- ingestion_metrics.insert(
575
- content_item_id: cid,
576
- input_tokens: 0,
577
- output_tokens: 0,
578
- facts_extracted: 0,
579
- created_at: now
580
- )
581
- end
582
-
583
- undistilled_ids.size
584
- end
585
-
586
- # --- LLM cache ---
587
-
588
- # Look up a cached LLM result by its cache key.
589
- # @param cache_key [String] SHA-256 hex cache key
590
- # @return [Hash, nil]
591
- def llm_cache_lookup(cache_key)
592
- llm_cache.where(cache_key: cache_key).first
593
- end
594
-
595
- # Store or update a cached LLM result. Uses upsert on the cache_key.
596
- # @param operation [String] operation name (e.g. "distill", "embed")
597
- # @param model [String] model identifier
598
- # @param input_hash [String] SHA-256 hex digest of the input
599
- # @param result_json [String] JSON-serialized result
600
- # @param input_tokens [Integer, nil] input tokens consumed
601
- # @param output_tokens [Integer, nil] output tokens consumed
602
- # @return [void]
603
- def llm_cache_store(operation:, model:, input_hash:, result_json:, input_tokens: nil, output_tokens: nil)
604
- cache_key = Digest::SHA256.hexdigest("#{operation}:#{model}:#{input_hash}")
605
-
606
- llm_cache
607
- .insert_conflict(target: :cache_key, update: {
608
- result_json: result_json,
609
- input_tokens: input_tokens,
610
- output_tokens: output_tokens,
611
- created_at: Time.now.utc.iso8601
612
- })
613
- .insert(
614
- cache_key: cache_key,
615
- operation: operation,
616
- model: model,
617
- input_hash: input_hash,
618
- result_json: result_json,
619
- input_tokens: input_tokens,
620
- output_tokens: output_tokens,
621
- created_at: Time.now.utc.iso8601
622
- )
623
- end
624
-
625
- # Compute the cache key for an LLM operation.
626
- # @param operation [String] operation name
627
- # @param model [String] model identifier
628
- # @param input [String] raw input text
629
- # @return [String] SHA-256 hex cache key
630
- def llm_cache_key(operation, model, input)
631
- input_hash = Digest::SHA256.hexdigest(input)
632
- Digest::SHA256.hexdigest("#{operation}:#{model}:#{input_hash}")
633
- end
634
-
635
- # Delete LLM cache entries older than the given age.
636
- # @param max_age_seconds [Integer] maximum age in seconds (default: 7 days)
637
- # @return [Integer] number of rows deleted
638
- def llm_cache_prune(max_age_seconds: 604_800)
639
- cutoff = (Time.now - max_age_seconds).utc.iso8601
640
- llm_cache.where { created_at < cutoff }.delete
641
- end
642
-
643
547
  # --- Meta ---
644
548
 
645
549
  # Set a key-value pair in the meta table (upsert).
@@ -118,6 +118,35 @@ module ClaudeMemory
118
118
  end
119
119
  end
120
120
 
121
+ # Return the store for an explicit scope only if its database file
122
+ # already exists on disk. Never creates a new DB. Useful for
123
+ # read-only surfaces that want to avoid accidental initialization.
124
+ # @param scope [String] "global" or "project"
125
+ # @return [SQLiteStore, nil]
126
+ def store_if_exists(scope)
127
+ case scope
128
+ when "project"
129
+ return nil unless project_exists?
130
+ ensure_project!
131
+ when "global"
132
+ return nil unless global_exists?
133
+ ensure_global!
134
+ end
135
+ end
136
+
137
+ # Return whichever store is available, preferring the requested scope.
138
+ # Falls back to the other scope if the preferred DB doesn't exist on
139
+ # disk yet. Returns nil only when both DBs are missing. Intended for
140
+ # "best-effort" surfaces like activity logging and default dashboard
141
+ # reads where the caller just needs some store to talk to.
142
+ # @param prefer [Symbol] :project (default) or :global
143
+ # @return [SQLiteStore, nil]
144
+ def default_store(prefer: :project)
145
+ primary = (prefer == :global) ? "global" : "project"
146
+ fallback = (prefer == :global) ? "project" : "global"
147
+ store_if_exists(primary) || store_if_exists(fallback)
148
+ end
149
+
121
150
  # Copy a project-scoped fact (with its entities and provenance) into the
122
151
  # global store, making it available across all projects. Runs the global
123
152
  # writes in a single transaction for atomicity.